From 8875c16b5218db9ddfe7f55123034e2f8ac24cea Mon Sep 17 00:00:00 2001 From: MarkusHorstmann Date: Tue, 27 Jun 2023 12:21:29 -0700 Subject: [PATCH 1/8] Explorer Search, Search URL and keywords, Creation Time (#179) * XmlSchemaUrl and CreationTime * Search: include keywords, category and contributor * Build: allow .csproj reference for OPC SDK for local development * Explorer: search and custom page size * Whitespace * Approval: fix for download of un-approved nodesets * Type usage statistics * Sync: read nodeset from file, pagination * SLN: add build target file --- CloudLibSync/CloudLibSync.cs | 18 +- CloudLibSync/CloudLibSync.csproj | 2 +- Directory.Build.targets | 35 +- Opc.Ua.CloudLib.Client/GraphQlExtensions.cs | 9 + Opc.Ua.CloudLib.Client/MetadataConverter.cs | 1 + Opc.Ua.CloudLib.Client/Models/UANameSpace.cs | 7 + .../Models/UANodesetResult.cs | 6 + Opc.Ua.CloudLib.Client/UACloudLibClient.cs | 17 +- .../CloudLibIntegrationTest.cs | 1 + .../CloudLibClientTests/QueriesAndDownload.cs | 27 +- UA-CloudLibrary.sln | 1 + UACloudLibraryServer/CloudLibDataProvider.cs | 18 +- .../CloudLibDataProviderLegacyMetadata.cs | 7 +- .../Components/Pages/Explorer.razor | 131 +- .../Controllers/InfoModelController.cs | 2 + .../DBContextModels/NamespaceMetaDataModel.cs | 1 + .../GraphQL/TypeUsageStats.cs | 187 ++ .../20230608191754_creationtime.Designer.cs | 1965 +++++++++++++++++ .../Migrations/20230608191754_creationtime.cs | 27 + .../ApplicationDbContextModelSnapshot.cs | 3 + UACloudLibraryServer/UA-CloudLibrary.csproj | 8 +- 21 files changed, 2427 insertions(+), 46 deletions(-) create mode 100644 UACloudLibraryServer/GraphQL/TypeUsageStats.cs create mode 100644 UACloudLibraryServer/Migrations/20230608191754_creationtime.Designer.cs create mode 100644 UACloudLibraryServer/Migrations/20230608191754_creationtime.cs diff --git a/CloudLibSync/CloudLibSync.cs b/CloudLibSync/CloudLibSync.cs index 14281377..09be2036 100644 --- a/CloudLibSync/CloudLibSync.cs +++ b/CloudLibSync/CloudLibSync.cs @@ -110,6 +110,13 @@ public async Task SynchronizeAsync(string sourceUrl, string sourceUserName, stri targetCursor = targetNodeSetResult.PageInfo.EndCursor; } while (targetNodeSetResult.PageInfo.HasNextPage); + targetCursor = null; + do + { + targetNodeSetResult = await targetClient.GetNodeSetsPendingApprovalAsync(after: targetCursor, first: 50).ConfigureAwait(false); + targetNodesets.AddRange(targetNodeSetResult.Edges.Select(e => e.Node)); + targetCursor = targetNodeSetResult.PageInfo.EndCursor; + } while (targetNodeSetResult.PageInfo.HasNextPage); bAdded = false; GraphQlResult sourceNodeSetResult; @@ -190,7 +197,16 @@ public async Task UploadAsync(string targetUrl, string targetUserName, string ta _logger.LogInformation($"Error uploading {file}: failed to parse."); continue; } - if (addressSpace.Nodeset == null) + if (addressSpace.Nodeset == null || string.IsNullOrEmpty(addressSpace.Nodeset.NodesetXml)) + { + var xmlFile = Path.Combine(Path.GetDirectoryName(file)??file, Path.GetFileNameWithoutExtension(file) + ".xml"); + if (File.Exists(xmlFile)) + { + var xml = File.ReadAllText(xmlFile); + addressSpace.Nodeset = new Nodeset { NodesetXml = xml }; + } + } + if (addressSpace.Nodeset == null || string.IsNullOrEmpty(addressSpace.Nodeset.NodesetXml)) { _logger.LogInformation($"Error uploading {file}: no Nodeset found in file."); continue; diff --git a/CloudLibSync/CloudLibSync.csproj b/CloudLibSync/CloudLibSync.csproj index 66c4babb..4d357708 100644 --- a/CloudLibSync/CloudLibSync.csproj +++ b/CloudLibSync/CloudLibSync.csproj @@ -9,7 +9,7 @@ - + diff --git a/Directory.Build.targets b/Directory.Build.targets index a140782c..ccf0df5f 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -13,7 +13,7 @@ - + $([System.IO.Path]::GetDirectoryName( $(SolutionPath) )) @@ -29,21 +29,46 @@ - + + %(Identity) + $(RegexPattern.Replace('[PackageName]','%(Identity)') ) + + + + $([System.Text.RegularExpressions.Regex]::Match( '$([System.IO.File]::ReadAllText($(SolutionPath)))', '%(Pattern)' )) + false + True + + + + + + + + + + + $(RegexPattern.Replace('[PackageName]','%(PackageName)') ) $([System.Text.RegularExpressions.Regex]::Match( '$([System.IO.File]::ReadAllText($(SolutionPath)))', '%(Pattern)' )) - + --> + - + diff --git a/Opc.Ua.CloudLib.Client/GraphQlExtensions.cs b/Opc.Ua.CloudLib.Client/GraphQlExtensions.cs index b84c26d6..6cef0286 100644 --- a/Opc.Ua.CloudLib.Client/GraphQlExtensions.cs +++ b/Opc.Ua.CloudLib.Client/GraphQlExtensions.cs @@ -30,6 +30,7 @@ namespace Opc.Ua.Cloud.Library.Client { using System; + using System.Linq.Expressions; using GraphQL.Query.Builder; static class GraphQlExtensions @@ -42,5 +43,13 @@ public static IQuery AddFields(this IQuery This, Func } return addFields(This); } + public static IQuery AddField(this IQuery This, Expression> selector, bool skip = false) + { + if (skip) + { + return This; + } + return This.AddField(selector); + } } } diff --git a/Opc.Ua.CloudLib.Client/MetadataConverter.cs b/Opc.Ua.CloudLib.Client/MetadataConverter.cs index 5a999959..8adbb0f5 100644 --- a/Opc.Ua.CloudLib.Client/MetadataConverter.cs +++ b/Opc.Ua.CloudLib.Client/MetadataConverter.cs @@ -87,6 +87,7 @@ public static UANameSpace Convert(UANodesetResult info) Description = info.Description, Category = info.Category, DocumentationUrl = info.DocumentationUrl, + CreationTime = info.CreationTime, IconUrl = info.IconUrl, LicenseUrl = info.LicenseUrl, Keywords = info.Keywords, diff --git a/Opc.Ua.CloudLib.Client/Models/UANameSpace.cs b/Opc.Ua.CloudLib.Client/Models/UANameSpace.cs index a5c06ba1..3eceba69 100644 --- a/Opc.Ua.CloudLib.Client/Models/UANameSpace.cs +++ b/Opc.Ua.CloudLib.Client/Models/UANameSpace.cs @@ -142,6 +142,13 @@ public UANameSpace() [JsonProperty("numberOfDownloads")] public uint NumberOfDownloads { get; set; } + /// + /// The time the nodeset was uploaded to the cloud library + /// + [JsonProperty("creationTime")] + public DateTime? CreationTime { get; set; } + + /// Gets or sets the validation status. /// Status: Parsed, Validaded, Error + message [JsonProperty("validationStatus")] diff --git a/Opc.Ua.CloudLib.Client/Models/UANodesetResult.cs b/Opc.Ua.CloudLib.Client/Models/UANodesetResult.cs index bc128c80..1b3b4bf9 100644 --- a/Opc.Ua.CloudLib.Client/Models/UANodesetResult.cs +++ b/Opc.Ua.CloudLib.Client/Models/UANodesetResult.cs @@ -141,6 +141,12 @@ public class UANodesetResult /// public uint NumberOfDownloads { get; set; } + /// + /// The time the nodeset was uploaded to the cloud library + /// + [JsonProperty(PropertyName = "creationTime")] + public System.DateTime? CreationTime { get; set; } + /// /// Additional properties /// diff --git a/Opc.Ua.CloudLib.Client/UACloudLibClient.cs b/Opc.Ua.CloudLib.Client/UACloudLibClient.cs index ef4c5e9a..0f6574fe 100644 --- a/Opc.Ua.CloudLib.Client/UACloudLibClient.cs +++ b/Opc.Ua.CloudLib.Client/UACloudLibClient.cs @@ -377,7 +377,7 @@ public async Task> GetOrganisationsAsync(int limit = 10, IEnu /// Queries the address spaces with the given filters and converts the result /// [Obsolete("Use GetNodeSetsAsync instead")] - public async Task> GetNameSpacesAsync(int limit = 10, int offset = 0, IEnumerable filter = null) + public async Task> GetNameSpacesAsync(int limit = 10, int offset = 0, IEnumerable filter = null, bool noCreationTime = true) { IQuery namespaceQuery = new Query("namespace", new QueryOptions { Formatter = CamelCasePropertyNameFormatter.Format }) .AddField(h => h.Title) @@ -399,6 +399,7 @@ public async Task> GetNameSpacesAsync(int limit = 10, int offs .AddField(h => h.CopyrightText) .AddField(h => h.Description) .AddField(h => h.DocumentationUrl) + .AddField(h => h.CreationTime, skip: noCreationTime) .AddField(h => h.IconUrl) .AddField(h => h.LicenseUrl) @@ -468,14 +469,15 @@ public async Task> GetNameSpacesAsync(int limit = 10, int offs /// /// Pagination: cursor of the last node in the previous page, use for forward paging /// Pagination: maximum number of nodes to return, use with after for forward paging. + /// Pagination: minimum number of nodes to return, use with before for backward paging. /// Pagination: cursor of the first node in the next page. Use for backward paging /// Don't request Nodeset.Metadata (performance) /// Don't request TotalCount (performance) /// Don't request Nodeset.RequiredModels (performance) - /// Pagination: minimum number of nodes to return, use with before for backward paging. + /// /// The metadata for the requested nodesets, as well as the metadata for all required notesets. public async Task> GetNodeSetsAsync(string identifier = null, string modelUri = null, DateTime? publicationDate = null, string[] keywords = null, - string after = null, int? first = null, int? last = null, string before = null, bool noMetadata = false, bool noTotalCount = false, bool noRequiredModels = false) + string after = null, int? first = null, int? last = null, string before = null, bool noMetadata = false, bool noTotalCount = false, bool noRequiredModels = false, bool noCreationTime = true) { var request = new GraphQLRequest(); IQuery> query = new Query>("nodeSets", new QueryOptions { Formatter = CamelCasePropertyNameFormatter.Format }) @@ -493,7 +495,7 @@ public async Task> GetNodeSetsAsync(string identifier = n .AddField(n => n.Version) .AddField(n => n.Identifier) .AddField(n => n.ValidationStatus) - .AddFields(AddMetadataFields, noMetadata) + .AddFields(q => AddMetadataFields(q, noCreationTime), noMetadata) .AddFields(AddRequiredModelFields, noRequiredModels) ) ) @@ -536,7 +538,7 @@ public async Task> GetNodeSetsAsync(string identifier = n } } - IQuery AddMetadataFields(IQuery query) + IQuery AddMetadataFields(IQuery query, bool noCreationTime) { return query.AddField(n => n.Metadata, mdq => mdq .AddField(n => n.Contributor, cq => cq @@ -558,6 +560,7 @@ IQuery AddMetadataFields(IQuery query) .AddField(n => n.CopyrightText) .AddField(n => n.Description) .AddField(n => n.DocumentationUrl) + .AddField(h => h.CreationTime, skip: noCreationTime) .AddField(n => n.IconUrl) .AddField(n => n.Keywords) .AddField(n => n.License) @@ -624,10 +627,11 @@ IQuery AddRequiredModelFields(IQuery query) /// /// /// + /// /// /// public async Task> GetNodeSetsPendingApprovalAsync(string namespaceUri = null, DateTime? publicationDate = null, UAProperty additionalProperty = null, - string after = null, int? first = null, int? last = null, string before = null, bool noMetadata = false, bool noTotalCount = false, bool noRequiredModels = false) + string after = null, int? first = null, int? last = null, string before = null, bool noMetadata = false, bool noTotalCount = false, bool noRequiredModels = false, bool noCreationTime = true) { var request = new GraphQLRequest(); var totalCountFragment = noTotalCount ? "" : "totalCount "; @@ -652,6 +656,7 @@ public async Task> GetNodeSetsPendingApprovalAsync(string copyrightText description documentationUrl + " + (noCreationTime ? "" : "creationTime") + @" iconUrl keywords license diff --git a/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs b/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs index e0aa8afb..8dcdf871 100644 --- a/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs +++ b/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs @@ -77,6 +77,7 @@ public static IEnumerable TestKeywords() return new List { new object[ ]{ null, 63 }, + new object[] { new string[] { "http://opcfoundation.org/UA/ADI/" }, 1 }, new object[] { new string[] { "BaseObjectType" }, 6 }, new object[] { new string[] { "di" }, 62 }, new object[] { new string[] { "robotics" }, 1 }, diff --git a/Tests/CloudLibClientTests/QueriesAndDownload.cs b/Tests/CloudLibClientTests/QueriesAndDownload.cs index 63e2921c..bebdb29c 100644 --- a/Tests/CloudLibClientTests/QueriesAndDownload.cs +++ b/Tests/CloudLibClientTests/QueriesAndDownload.cs @@ -194,6 +194,7 @@ public async Task DownloadNodesetAsync() Assert.Equal(uploadedNamespace.CopyrightText, downloadedNamespace.CopyrightText); Assert.Equal(uploadedNamespace.Description, downloadedNamespace.Description); Assert.Equal(uploadedNamespace.DocumentationUrl, downloadedNamespace.DocumentationUrl); + Assert.True(downloadedNamespace.CreationTime != null && DateTime.Now - downloadedNamespace.CreationTime < new TimeSpan(1, 0, 0)); Assert.Equal(uploadedNamespace.IconUrl, downloadedNamespace.IconUrl); Assert.Equal(uploadedNamespace.PurchasingInformationUrl, downloadedNamespace.PurchasingInformationUrl); Assert.Equal(uploadedNamespace.ReleaseNotesUrl, downloadedNamespace.ReleaseNotesUrl); @@ -243,13 +244,14 @@ public async Task GetNamespaceIdsAsync() } [Theory] - [InlineData(true, true, true)] - [InlineData(false, false, false)] - [InlineData(true, false, false)] - [InlineData(false, true, false)] - [InlineData(false, false, true)] - - public async Task GetNodeSetsAsync(bool noMetadata, bool noRequiredModels, bool noTotalCount) + [InlineData(true, true, true, true)] + [InlineData(false, true, true, false)] + [InlineData(false, false, false, false)] + [InlineData(true, false, false, false)] + [InlineData(false, true, false, false)] + [InlineData(false, false, true, false)] + + public async Task GetNodeSetsAsync(bool noMetadata, bool noRequiredModels, bool noTotalCount, bool noCreationTime) { var client = _factory.CreateCloudLibClient(); string cursor = null; @@ -259,7 +261,7 @@ public async Task GetNodeSetsAsync(bool noMetadata, bool noRequiredModels, bool int? totalCount = null; do { - var result = await client.GetNodeSetsAsync(after: cursor, first: limit, noRequiredModels: noRequiredModels, noMetadata: noMetadata, noTotalCount: noTotalCount); + var result = await client.GetNodeSetsAsync(after: cursor, first: limit, noRequiredModels: noRequiredModels, noMetadata: noMetadata, noTotalCount: noTotalCount, noCreationTime: noCreationTime); Assert.True(cursor == null || result.Edges?.Count > 0, "Failed to get node set information."); testNodeSet = result.Edges.FirstOrDefault(n => n.Node.NamespaceUri.OriginalString == strTestNamespaceUri)?.Node; @@ -300,6 +302,14 @@ public async Task GetNodeSetsAsync(bool noMetadata, bool noRequiredModels, bool Assert.Equal(uploadedNamespace.CopyrightText, testNodeSet.Metadata.CopyrightText); Assert.Equal(uploadedNamespace.Description, testNodeSet.Metadata.Description); Assert.Equal(uploadedNamespace.DocumentationUrl, testNodeSet.Metadata.DocumentationUrl); + if (noCreationTime) + { + Assert.Null(testNodeSet.Metadata.CreationTime); + } + else + { + Assert.True(testNodeSet.Metadata.CreationTime != null && DateTime.Now - testNodeSet.Metadata.CreationTime < new TimeSpan(1, 0, 0)); + } Assert.Equal(uploadedNamespace.IconUrl, testNodeSet.Metadata.IconUrl); Assert.Equal(uploadedNamespace.PurchasingInformationUrl, testNodeSet.Metadata.PurchasingInformationUrl); Assert.Equal(uploadedNamespace.ReleaseNotesUrl, testNodeSet.Metadata.ReleaseNotesUrl); @@ -379,6 +389,7 @@ public async Task GetConvertedMetadataAsync(bool forceRest) Assert.Equal(uploadedNamespace.CopyrightText, convertedMetaData.CopyrightText); Assert.Equal(uploadedNamespace.Description, convertedMetaData.Description); Assert.Equal(uploadedNamespace.DocumentationUrl, convertedMetaData.DocumentationUrl); + Assert.Null(uploadedNamespace.CreationTime); Assert.Equal(uploadedNamespace.IconUrl, convertedMetaData.IconUrl); Assert.Equal(uploadedNamespace.PurchasingInformationUrl, convertedMetaData.PurchasingInformationUrl); Assert.Equal(uploadedNamespace.ReleaseNotesUrl, convertedMetaData.ReleaseNotesUrl); diff --git a/UA-CloudLibrary.sln b/UA-CloudLibrary.sln index 04b540e1..f56a86c1 100644 --- a/UA-CloudLibrary.sln +++ b/UA-CloudLibrary.sln @@ -11,6 +11,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution azure-pipelines-preview.yml = azure-pipelines-preview.yml common.props = common.props Directory.Build.props = Directory.Build.props + Directory.Build.targets = Directory.Build.targets .github\workflows\docker.yml = .github\workflows\docker.yml Dockerfile = Dockerfile local-dev-docker\docker_pgadmin_servers.json = local-dev-docker\docker_pgadmin_servers.json diff --git a/UACloudLibraryServer/CloudLibDataProvider.cs b/UACloudLibraryServer/CloudLibDataProvider.cs index 134d4c0e..33da841a 100644 --- a/UACloudLibraryServer/CloudLibDataProvider.cs +++ b/UACloudLibraryServer/CloudLibDataProvider.cs @@ -246,7 +246,7 @@ await _dbContext.Categories.FirstOrDefaultAsync(c => c.Name == uaNamespace.Categ public async Task IncrementDownloadCountAsync(uint nodesetId) { - var namespaceMeta = await _dbContext.NamespaceMetaData.FirstOrDefaultAsync(n => n.NodesetId == nodesetId.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + var namespaceMeta = await _dbContext.NamespaceMetaDataWithUnapproved.FirstOrDefaultAsync(n => n.NodesetId == nodesetId.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); namespaceMeta.NumberOfDownloads++; var newCount = namespaceMeta.NumberOfDownloads; await _dbContext.SaveChangesAsync(); @@ -315,10 +315,12 @@ public async Task RetrieveAllMetadataAsync(uint nodesetId) UANameSpace nameSpace = new(); try { +#pragma warning disable CA1305 // Specify IFormatProvider: runs in database with single culture, can not use culture invariant var namespaceModel = await _dbContext.NamespaceMetaDataWithUnapproved .Where(md => md.NodesetId == nodesetId.ToString()) .Include(md => md.NodeSet) .FirstOrDefaultAsync().ConfigureAwait(false); +#pragma warning restore CA1305 // Specify IFormatProvider if (namespaceModel == null) { return null; @@ -340,18 +342,18 @@ internal IQueryable SearchNodesets(string[] keywords) if (keywords?.Any() == true && keywords[0] != "*") { string keywordRegex = $".*({string.Join('|', keywords)}).*"; - string keywordTsQueryText = string.Join(" | ", - keywords - .Select(k => $"'{Regex.Replace(k, "\\d", "").Trim(' ')}'") - ); // PostgreSql text search doesn't like numbers: remove them for now - var keywordTsQuery = NpgsqlTsQuery.Parse(keywordTsQueryText); #pragma warning disable CA1305 // Specify IFormatProvider - ToString() runs in the database, cultureinfo not supported matchingNodeSets = _dbContext.nodeSets .Where(nsm => _dbContext.NamespaceMetaData.Any(md => md.NodesetId == nsm.Identifier - && Regex.IsMatch(md.Title + md.Description, keywordRegex, RegexOptions.IgnoreCase) + && (Regex.IsMatch(md.Title, keywordRegex, RegexOptions.IgnoreCase) + || Regex.IsMatch(md.Description, keywordRegex, RegexOptions.IgnoreCase) + || Regex.IsMatch(md.NodeSet.ModelUri, keywordRegex, RegexOptions.IgnoreCase) + || Regex.IsMatch(string.Join(",", md.Keywords), keywordRegex, RegexOptions.IgnoreCase) + || Regex.IsMatch(md.Category.Name, keywordRegex, RegexOptions.IgnoreCase) + || Regex.IsMatch(md.Contributor.Name, keywordRegex, RegexOptions.IgnoreCase)) // Fulltext appears to be slower than regex: && EF.Functions.ToTsVector("english", md.Title + " || " + md.Description/* + " " + string.Join(' ', md.Keywords) + md.Category.Name + md.Contributor.Name*/).Matches(keywordTsQuery)) ) @@ -524,6 +526,7 @@ private void MapToEntity(ref NamespaceMetaDataModel entity, UANameSpace uaNamesp { var identifier = nodeSetModel != null ? nodeSetModel.Identifier : uaNamespace.Nodeset.Identifier.ToString(CultureInfo.InvariantCulture); entity.NodesetId = identifier; + entity.CreationTime = uaNamespace.CreationTime ?? DateTime.Now; entity.NodeSet = nodeSetModel; entity.Title = uaNamespace.Title; entity.ContributorId = contributor?.Id ?? 0; @@ -573,6 +576,7 @@ private void MapToNamespace(UANameSpace uaNamespace, NamespaceMetaDataModel mode { MapToNodeSet(uaNamespace.Nodeset, model.NodeSet); } + uaNamespace.CreationTime = model.CreationTime; uaNamespace.Title = model.Title; if (model.Contributor == null) { diff --git a/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs b/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs index 185b811d..a134a5d9 100644 --- a/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs +++ b/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs @@ -120,6 +120,11 @@ private static void ConvertNodeSetMetadata(uint nodesetId, List m nameSpace.Nodeset.LastModifiedDate = parsedDateTime; } + if (DateTime.TryParse(allMetaData.GetValueOrDefault("creationtime"), out parsedDateTime)) + { + nameSpace.CreationTime = parsedDateTime; + } + nameSpace.Title = allMetaData.GetValueOrDefault("nodesettitle", string.Empty); nameSpace.Nodeset.Version = allMetaData.GetValueOrDefault("version", string.Empty); @@ -269,7 +274,7 @@ private static void ConvertNodeSetMetadata(uint nodesetId, List m } static readonly string[] _knownProperties = new string[] { - "addressspacedescription", "addressspaceiconurl", "addressspacename", "copyright", "description", "documentationurl", "iconurl", + "addressspacedescription", "addressspaceiconurl", "addressspacename", "copyright", "creationtime", "description", "documentationurl", "iconurl", "keywords", "license", "licenseurl", "locales", "nodesetcreationtime", "nodesetmodifiedtime", "nodesettitle", "numdownloads", "orgcontact", "orgdescription", "orglogo", "orgname", "orgwebsite", "purchasinginfo", "releasenotes", "testspecification", "validationstatus", "version", }; diff --git a/UACloudLibraryServer/Components/Pages/Explorer.razor b/UACloudLibraryServer/Components/Pages/Explorer.razor index 5b4debdc..2249d504 100644 --- a/UACloudLibraryServer/Components/Pages/Explorer.razor +++ b/UACloudLibraryServer/Components/Pages/Explorer.razor @@ -26,6 +26,13 @@ } else { + +
+
+ Search +
+ +
@for (int row = 0; row < Math.Ceiling(Result.Count / (double)COL_SIZE); ++row) {
@@ -77,11 +84,64 @@
+
+
+ Items per page +
+ +
@@ -98,6 +158,9 @@ + - - - @if (ShowBackDrop) { @@ -119,6 +179,28 @@ @inject IDatabase dp @code { + private void OnSearchTextChanged(Microsoft.AspNetCore.Components.ChangeEventArgs patharg) + { + var searchText = patharg.Value?.ToString(); + var keywords = searchText?.Split(",").Select(k => k.Trim()).ToArray(); + keywords = keywords != null && keywords.Length > 0 && !string.IsNullOrEmpty(keywords[0]) ? keywords : new[] { "*" }; + + if (!SearchKeywords.SequenceEqual(keywords)) + { + SearchKeywords = keywords; + CurrentPage = 1; + fetchData(); + } + } + private void OnPageSizeChanged(Microsoft.AspNetCore.Components.ChangeEventArgs patharg) + { + if (int.TryParse(patharg.Value?.ToString(), out var pageSize) && pageSize > 1 && pageSize <= 100) + { + PAGE_SIZE = pageSize; + CurrentPage = 1; + fetchData(); + } + } private string ModalClass = ""; private string ModalDisplay = "none"; private bool ShowBackDrop = false; @@ -127,17 +209,22 @@ private string ModalTitle = ""; private string ModalDescription = ""; + private string ModalDocumentationUrl = ""; - const int PAGE_SIZE = 6; + int PAGE_SIZE = 6; const int COL_SIZE = 3; public bool Loading { get; set; } = true; private List Result = new(); private int CurrentPage = 1; + private int FirstItemOnPage = 0; + private int LastItemOnPage = 0; private string FilterString = ""; private int TotalCount = 0; private int AllPages = 0; + private string[] SearchKeywords { get; set; } = new[] { "*" }; + protected override void OnInitialized() { @@ -159,6 +246,7 @@ ShowBackDrop = true; ModalTitle = item.Title; ModalDescription = item.Description; + ModalDocumentationUrl = item.DocumentationUrl; CurrentItem = item; StateHasChanged(); } @@ -171,14 +259,19 @@ StateHasChanged(); } - private void changePage(int increment) + private void changePageAbsolute(int requestedPage) { - var newPage = (this.TotalCount > 0) ? Math.Clamp(this.CurrentPage + increment, 1, (int)Math.Ceiling((decimal)this.TotalCount / PAGE_SIZE)) : 0; + var newPage = (this.TotalCount > 0) ? Math.Clamp(requestedPage, 1, (int)Math.Ceiling((decimal)this.TotalCount / PAGE_SIZE)) : 0; if (!CurrentPage.Equals(newPage)) { CurrentPage = newPage; fetchData(); } + + } + private void changePage(int increment) + { + changePageAbsolute(this.CurrentPage + increment); } private async void fetchData() @@ -187,13 +280,25 @@ var query = HttpUtility.ParseQueryString(string.Empty); query["page"] = this.CurrentPage.ToString(); query["filterString"] = this.FilterString; - - TotalCount = dp.GetNamespaceTotalCount(); - Result = await dp.GetNamespaces() - .OrderBy(md => md.Category.Name) + var dbQuery = dp.GetNodeSets(keywords: SearchKeywords) + .Select(n => n.Metadata) + .OrderBy(md => md.Category.Name); + TotalCount = dbQuery.Count(); + int skip = (CurrentPage - 1) * PAGE_SIZE; + Result = await dbQuery .Skip((CurrentPage - 1) * PAGE_SIZE).Take(PAGE_SIZE) .ToListAsync().ConfigureAwait(true); AllPages = (int)Math.Ceiling((decimal)this.TotalCount / PAGE_SIZE); + if (Result.Count > 0) + { + FirstItemOnPage = skip + 1; + LastItemOnPage = FirstItemOnPage + Result.Count - 1; + } + else + { + FirstItemOnPage = 0; + LastItemOnPage = 0; + } Loading = false; StateHasChanged(); diff --git a/UACloudLibraryServer/Controllers/InfoModelController.cs b/UACloudLibraryServer/Controllers/InfoModelController.cs index eaeb26ec..afd6a9b6 100644 --- a/UACloudLibraryServer/Controllers/InfoModelController.cs +++ b/UACloudLibraryServer/Controllers/InfoModelController.cs @@ -282,6 +282,8 @@ public async Task UploadNamespaceAsync( return new ObjectResult("Contributor name of existing nodeset is different to the one provided.") { StatusCode = (int)HttpStatusCode.Conflict }; } + uaNamespace.CreationTime = DateTime.UtcNow; + if (uaNamespace.Nodeset.PublicationDate != nodeSet.Models[0].PublicationDate) { _logger.LogInformation("PublicationDate in metadata does not match nodeset XML. Ignoring."); diff --git a/UACloudLibraryServer/GraphQL/DBContextModels/NamespaceMetaDataModel.cs b/UACloudLibraryServer/GraphQL/DBContextModels/NamespaceMetaDataModel.cs index 290ecf09..1be603b8 100644 --- a/UACloudLibraryServer/GraphQL/DBContextModels/NamespaceMetaDataModel.cs +++ b/UACloudLibraryServer/GraphQL/DBContextModels/NamespaceMetaDataModel.cs @@ -43,6 +43,7 @@ namespace Opc.Ua.Cloud.Library.DbContextModels public partial class NamespaceMetaDataModel { public string NodesetId { get; set; } + public DateTime CreationTime { get; set; } public virtual CloudLibNodeSetModel NodeSet { get; set; } public string Title { get; set; } public int ContributorId { get; set; } diff --git a/UACloudLibraryServer/GraphQL/TypeUsageStats.cs b/UACloudLibraryServer/GraphQL/TypeUsageStats.cs new file mode 100644 index 00000000..8af7e092 --- /dev/null +++ b/UACloudLibraryServer/GraphQL/TypeUsageStats.cs @@ -0,0 +1,187 @@ +using System.Collections.Generic; +using System.Linq; +using CESMII.OpcUa.NodeSetModel; +using HotChocolate; +using HotChocolate.Data; +using Microsoft.EntityFrameworkCore; + +namespace Opc.Ua.Cloud.Library +{ + public partial class QueryModel + { + [UseFiltering, UseSorting] + public List GetTypeUsageStats([Service(ServiceKind.Synchronized)] IDatabase dp) + { + return (dp as CloudLibDataProvider).GetTypeUsageStats(); + } + } + + public class TypeStats + { + public string Namespace { get; set; } + public string Name { get; set; } + public string NodeClass { get; set; } + public int SubTypeCount { get; set; } + public int SubTypeExternalCount { get; set; } + public int ComponentCount { get; set; } + public int ComponentExternalCount { get; set; } + public IEnumerable NodeSetsExternal { get; set; } + public int NodeSetsExternalCount => NodeSetsExternal.Count(); + } + public partial class CloudLibDataProvider + { + public /*Dictionary GetTypeUsageStats() + { + var objectTypesInObjectsStats = _dbContext.nodeSets + .SelectMany(nm => nm.Objects/*.Where(o => o.NodeSet.ModelUri != o.TypeDefinition.NodeSet.ModelUri)*/ + .Select(om => new { ObjectType = om.TypeDefinition, Namespace = om.NodeSet.ModelUri, BrowseName = om.BrowseName })) + .Distinct() + .ToList() + .GroupBy(a => a.ObjectType.BrowseName, (key, a) => + a.Select(a => new TypeStats { + Namespace = a.ObjectType.NodeSet.ModelUri, + Name = key, + NodeClass = nameof(ObjectTypeModel), + ComponentCount = 1, + ComponentExternalCount = a.ObjectType.NodeSet.ModelUri != a.Namespace ? 1 : 0, + NodeSetsExternal = new List { a.Namespace } + }) + .Aggregate((ts1, ts2) => new TypeStats { + Name = ts1.Name, + Namespace = ts1.Namespace, + NodeClass = ts1.NodeClass, + SubTypeCount = ts1.SubTypeCount + ts2.SubTypeCount, + SubTypeExternalCount = ts1.SubTypeExternalCount + ts2.SubTypeExternalCount, + ComponentCount = ts1.ComponentCount + ts2.ComponentCount, + ComponentExternalCount = ts1.ComponentExternalCount + ts2.ComponentExternalCount, + NodeSetsExternal = ts1.NodeSetsExternal.Union(ts2.NodeSetsExternal) + })) + ; + + var dataTypeInVariablesStats = _dbContext.nodeSets + .SelectMany(nm => nm.Properties/*.Where(p => p.NodeSet.ModelUri != p.TypeDefinition.NodeSet.ModelUri)*/ + .Select(p => new { DataType = p.DataType, Namespace = p.NodeSet.ModelUri, BrowseName = p.BrowseName })) + .Distinct() + .ToList() + .Concat( + _dbContext.nodeSets + .SelectMany(nm => nm.DataVariables/*.Where(dv => dv.NodeSet.ModelUri != dv.TypeDefinition.NodeSet.ModelUri)*/ + .Select(dv => new { DataType = dv.DataType, Namespace = dv.NodeSet.ModelUri, BrowseName = dv.BrowseName })) + .Distinct() + .ToList() + ) + .GroupBy(a => a.DataType.BrowseName, (key, a) => + a.Select(a => new TypeStats { + Namespace = a.DataType.NodeSet.ModelUri, + Name = key, + NodeClass = nameof(DataTypeModel), + ComponentCount = 1, + ComponentExternalCount = a.DataType.NodeSet.ModelUri != a.Namespace ? 1 : 0, + NodeSetsExternal = new List { a.Namespace } + }) + .Aggregate((ts1, ts2) => new TypeStats { + Name = ts1.Name, + Namespace = ts1.Namespace, + NodeClass = ts1.NodeClass, + SubTypeCount = ts1.SubTypeCount + ts2.SubTypeCount, + SubTypeExternalCount = ts1.SubTypeExternalCount + ts2.SubTypeExternalCount, + ComponentCount = ts1.ComponentCount + ts2.ComponentCount, + ComponentExternalCount = ts1.ComponentExternalCount + ts2.ComponentExternalCount, + NodeSetsExternal = ts1.NodeSetsExternal.Union(ts2.NodeSetsExternal) + })) + ; + + var dataTypeInStructsStats = _dbContext.nodeSets + .SelectMany(nm => nm.DataTypes.SelectMany(dt => dt.StructureFields.Select(sf => new { StructureFieldDT = sf.DataType, ReferencingNamespace = dt.NodeSet.ModelUri, ReferencingBrowseName = dt.BrowseName }))/*.Where(o => o.NodeSet.ModelUri != o.TypeDefinition.NodeSet.ModelUri)*/ + .Select(sf => new { DataType = sf.StructureFieldDT, Namespace = sf.ReferencingNamespace, BrowseName = sf.ReferencingBrowseName })) + .Distinct() + .ToList() + .GroupBy(a => a.DataType.BrowseName, (key, a) => + a.Select(a => new TypeStats { + Namespace = a.DataType.NodeSet.ModelUri, + Name = key, + NodeClass = nameof(DataTypeModel), + ComponentCount = 1, + ComponentExternalCount = a.DataType.NodeSet.ModelUri != a.Namespace ? 1 : 0, + NodeSetsExternal = new List { a.Namespace } + }) + .Aggregate((ts1, ts2) => new TypeStats { + Name = ts1.Name, + Namespace = ts1.Namespace, + NodeClass = ts1.NodeClass, + SubTypeCount = ts1.SubTypeCount + ts2.SubTypeCount, + SubTypeExternalCount = ts1.SubTypeExternalCount + ts2.SubTypeExternalCount, + ComponentCount = ts1.ComponentCount + ts2.ComponentCount, + ComponentExternalCount = ts1.ComponentExternalCount + ts2.ComponentExternalCount, + NodeSetsExternal = ts1.NodeSetsExternal.Union(ts2.NodeSetsExternal) + })) + ; + + var objectTypeSubTypeStats = _dbContext.nodeSets + .SelectMany(n => n.ObjectTypes) + .Where(o => o.SubTypes.Any()) + .Select(ot => new TypeStats { + Name = ot.BrowseName, + Namespace = ot.NodeSet.ModelUri, + NodeClass = nameof(ObjectTypeModel), + SubTypeCount = ot.SubTypes.Count, + SubTypeExternalCount = ot.SubTypes.Where(st => st.SuperType.NodeSet.ModelUri != st.NodeSet.ModelUri).Count(), + NodeSetsExternal = ot.SubTypes.Where(st => st.SuperType.NodeSet.ModelUri != st.NodeSet.ModelUri).Select(st => st.NodeSet.ModelUri).Distinct(), + }) + .ToList(); + + var variableTypeStats = _dbContext.nodeSets + .SelectMany(n => n.VariableTypes) + .Where(o => o.SubTypes.Any()) + .Select(ot => new TypeStats { + Name = ot.BrowseName, + Namespace = ot.NodeSet.ModelUri, + NodeClass = nameof(VariableTypeModel), + SubTypeCount = ot.SubTypes.Count, + SubTypeExternalCount = ot.SubTypes.Where(st => st.SuperType.NodeSet.ModelUri != st.NodeSet.ModelUri).Count(), + NodeSetsExternal = ot.SubTypes.Where(st => st.SuperType.NodeSet.ModelUri != st.NodeSet.ModelUri).Select(st => st.NodeSet.ModelUri).Distinct(), + }) + .ToList(); + + var dataTypeStats = _dbContext.nodeSets + .SelectMany(n => n.DataTypes) + .Where(o => o.SubTypes.Any()) + .Select(ot => new TypeStats { + Name = ot.BrowseName, + Namespace = ot.NodeSet.ModelUri, + NodeClass = nameof(DataTypeModel), + SubTypeCount = ot.SubTypes.Count, + SubTypeExternalCount = ot.SubTypes.Where(st => st.SuperType.NodeSet.ModelUri != st.NodeSet.ModelUri).Count(), + NodeSetsExternal = ot.SubTypes.Where(st => st.SuperType.NodeSet.ModelUri != st.NodeSet.ModelUri).Select(st => st.NodeSet.ModelUri).Distinct(), + }) + .ToList(); + + + var combinedStats = + objectTypesInObjectsStats + .Concat(objectTypeSubTypeStats) + .Concat(dataTypeStats) + .Concat(dataTypeInVariablesStats) + .Concat(dataTypeInStructsStats) + .Concat(variableTypeStats) + //.GroupBy(ts => ts.Namespace) + //.ToDictionary(g => g.Key, g => g + .GroupBy(ts => ts.Name, (key, tsList) => tsList.Aggregate((ts1, ts2) => new TypeStats { + Name = ts1.Name, + Namespace = ts1.Namespace, + NodeClass = ts1.NodeClass, + SubTypeCount = ts1.SubTypeCount + ts2.SubTypeCount, + SubTypeExternalCount = ts1.SubTypeExternalCount + ts2.SubTypeExternalCount, + ComponentCount = ts1.ComponentCount + ts2.ComponentCount, + ComponentExternalCount = ts1.ComponentExternalCount + ts2.ComponentExternalCount, + NodeSetsExternal = ts1.NodeSetsExternal.Union(ts2.NodeSetsExternal) + })) + //.OrderByDescending(ts => ts.SubTypeExternalCount) + .ToList() + ; + + return combinedStats; + } + + } +} diff --git a/UACloudLibraryServer/Migrations/20230608191754_creationtime.Designer.cs b/UACloudLibraryServer/Migrations/20230608191754_creationtime.Designer.cs new file mode 100644 index 00000000..61ea6bba --- /dev/null +++ b/UACloudLibraryServer/Migrations/20230608191754_creationtime.Designer.cs @@ -0,0 +1,1965 @@ +// +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Opc.Ua.Cloud.Library; + +#nullable disable + +namespace Opc.Ua.Cloud.Library +{ + [DbContext(typeof(AppDbContext))] + [Migration("20230608191754_creationtime")] + partial class creationtime + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.NodeModel", b => { + b.Property("NodeId") + .HasColumnType("text"); + + b.Property("NodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("BrowseName") + .HasColumnType("text"); + + b.Property>("Categories") + .HasColumnType("text[]"); + + b.Property("Documentation") + .HasColumnType("text"); + + b.Property("NodeSetUnknownNodesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetUnknownNodesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReleaseStatus") + .HasColumnType("text"); + + b.Property("SymbolicName") + .HasColumnType("text"); + + b.HasKey("NodeId", "NodeSetModelUri", "NodeSetPublicationDate"); + + b.HasIndex("BrowseName") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("BrowseName"), "GIN"); + + b.HasIndex("NodeSetModelUri", "NodeSetPublicationDate"); + + b.HasIndex("NodeSetUnknownNodesModelUri", "NodeSetUnknownNodesPublicationDate"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.NodeSetModel", b => { + b.Property("ModelUri") + .HasColumnType("text"); + + b.Property("PublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Discriminator") + .IsRequired() + .HasColumnType("text"); + + b.Property("Identifier") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("text"); + + b.Property("XmlSchemaUri") + .HasColumnType("text"); + + b.HasKey("ModelUri", "PublicationDate"); + + b.HasAlternateKey("Identifier"); + + b.ToTable("NodeSets", (string)null); + + b.HasDiscriminator("Discriminator").HasValue("NodeSetModel"); + }); + + modelBuilder.Entity("DataVariableModelNodeModel", b => { + b.Property("DataVariablesNodeId") + .HasColumnType("text"); + + b.Property("DataVariablesNodeSetModelUri") + .HasColumnType("text"); + + b.Property("DataVariablesNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NodesWithDataVariablesNodeId") + .HasColumnType("text"); + + b.Property("NodesWithDataVariablesNodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodesWithDataVariablesNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("DataVariablesNodeId", "DataVariablesNodeSetModelUri", "DataVariablesNodeSetPublicationDate", "NodesWithDataVariablesNodeId", "NodesWithDataVariablesNodeSetModelUri", "NodesWithDataVariablesNodeSetPublicationDate"); + + b.HasIndex("NodesWithDataVariablesNodeId", "NodesWithDataVariablesNodeSetModelUri", "NodesWithDataVariablesNodeSetPublicationDate"); + + b.ToTable("DataVariableModelNodeModel"); + }); + + modelBuilder.Entity("InterfaceModelNodeModel", b => { + b.Property("InterfacesNodeId") + .HasColumnType("text"); + + b.Property("InterfacesNodeSetModelUri") + .HasColumnType("text"); + + b.Property("InterfacesNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NodesWithInterfaceNodeId") + .HasColumnType("text"); + + b.Property("NodesWithInterfaceNodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodesWithInterfaceNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("InterfacesNodeId", "InterfacesNodeSetModelUri", "InterfacesNodeSetPublicationDate", "NodesWithInterfaceNodeId", "NodesWithInterfaceNodeSetModelUri", "NodesWithInterfaceNodeSetPublicationDate"); + + b.HasIndex("NodesWithInterfaceNodeId", "NodesWithInterfaceNodeSetModelUri", "NodesWithInterfaceNodeSetPublicationDate"); + + b.ToTable("InterfaceModelNodeModel"); + }); + + modelBuilder.Entity("MethodModelNodeModel", b => { + b.Property("MethodsNodeId") + .HasColumnType("text"); + + b.Property("MethodsNodeSetModelUri") + .HasColumnType("text"); + + b.Property("MethodsNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NodesWithMethodsNodeId") + .HasColumnType("text"); + + b.Property("NodesWithMethodsNodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodesWithMethodsNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("MethodsNodeId", "MethodsNodeSetModelUri", "MethodsNodeSetPublicationDate", "NodesWithMethodsNodeId", "NodesWithMethodsNodeSetModelUri", "NodesWithMethodsNodeSetPublicationDate"); + + b.HasIndex("NodesWithMethodsNodeId", "NodesWithMethodsNodeSetModelUri", "NodesWithMethodsNodeSetPublicationDate"); + + b.ToTable("MethodModelNodeModel"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderKey") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Name") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("NodeModelObjectModel", b => { + b.Property("NodesWithObjectsNodeId") + .HasColumnType("text"); + + b.Property("NodesWithObjectsNodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodesWithObjectsNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ObjectsNodeId") + .HasColumnType("text"); + + b.Property("ObjectsNodeSetModelUri") + .HasColumnType("text"); + + b.Property("ObjectsNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("NodesWithObjectsNodeId", "NodesWithObjectsNodeSetModelUri", "NodesWithObjectsNodeSetPublicationDate", "ObjectsNodeId", "ObjectsNodeSetModelUri", "ObjectsNodeSetPublicationDate"); + + b.HasIndex("ObjectsNodeId", "ObjectsNodeSetModelUri", "ObjectsNodeSetPublicationDate"); + + b.ToTable("NodeModelObjectModel"); + }); + + modelBuilder.Entity("NodeModelObjectTypeModel", b => { + b.Property("EventsNodeId") + .HasColumnType("text"); + + b.Property("EventsNodeSetModelUri") + .HasColumnType("text"); + + b.Property("EventsNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NodesWithEventsNodeId") + .HasColumnType("text"); + + b.Property("NodesWithEventsNodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodesWithEventsNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("EventsNodeId", "EventsNodeSetModelUri", "EventsNodeSetPublicationDate", "NodesWithEventsNodeId", "NodesWithEventsNodeSetModelUri", "NodesWithEventsNodeSetPublicationDate"); + + b.HasIndex("NodesWithEventsNodeId", "NodesWithEventsNodeSetModelUri", "NodesWithEventsNodeSetPublicationDate"); + + b.ToTable("NodeModelObjectTypeModel"); + }); + + modelBuilder.Entity("NodeModelVariableModel", b => { + b.Property("NodesWithPropertiesNodeId") + .HasColumnType("text"); + + b.Property("NodesWithPropertiesNodeSetModelUri") + .HasColumnType("text"); + + b.Property("NodesWithPropertiesNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PropertiesNodeId") + .HasColumnType("text"); + + b.Property("PropertiesNodeSetModelUri") + .HasColumnType("text"); + + b.Property("PropertiesNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("NodesWithPropertiesNodeId", "NodesWithPropertiesNodeSetModelUri", "NodesWithPropertiesNodeSetPublicationDate", "PropertiesNodeId", "PropertiesNodeSetModelUri", "PropertiesNodeSetPublicationDate"); + + b.HasIndex("PropertiesNodeId", "PropertiesNodeSetModelUri", "PropertiesNodeSetPublicationDate"); + + b.ToTable("NodeModelVariableModel"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DbContextModels.CategoryModel", b => { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("IconUrl") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DbContextModels.MetadataModel", b => { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("metadata_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("metadata_name"); + + b.Property("NodesetId") + .HasColumnType("bigint") + .HasColumnName("nodeset_id"); + + b.Property("Value") + .HasColumnType("text") + .HasColumnName("metadata_value"); + + b.HasKey("Id"); + + b.ToTable("metadata"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DbContextModels.NamespaceMetaDataModel", b => { + b.Property("NodesetId") + .HasColumnType("text"); + + b.Property("ApprovalInformation") + .HasColumnType("text"); + + b.Property("ApprovalStatus") + .HasColumnType("text"); + + b.Property("CategoryId") + .HasColumnType("integer"); + + b.Property("ContributorId") + .HasColumnType("integer"); + + b.Property("CopyrightText") + .HasColumnType("text"); + + b.Property("CreationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("DocumentationUrl") + .HasColumnType("text"); + + b.Property("IconUrl") + .HasColumnType("text"); + + b.Property("Keywords") + .HasColumnType("text[]"); + + b.Property("License") + .HasColumnType("text"); + + b.Property("LicenseUrl") + .HasColumnType("text"); + + b.Property("NumberOfDownloads") + .HasColumnType("bigint"); + + b.Property("PurchasingInformationUrl") + .HasColumnType("text"); + + b.Property("ReleaseNotesUrl") + .HasColumnType("text"); + + b.Property("SupportedLocales") + .HasColumnType("text[]"); + + b.Property("TestSpecificationUrl") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("NodesetId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ContributorId"); + + b.HasIndex("Title", "Description") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title", "Description"), "GIN"); + + b.ToTable("NamespaceMeta"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DbContextModels.OrganisationModel", b => { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ContactEmail") + .HasColumnType("text"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Website") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Organisations"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DevDbFiles", b => { + b.Property("Name") + .HasColumnType("text"); + + b.Property("Blob") + .HasColumnType("text"); + + b.HasKey("Name"); + + b.ToTable("DevDbFiles"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.BaseTypeModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.NodeModel"); + + b.Property("IsAbstract") + .HasColumnType("boolean"); + + b.Property("SuperTypeNodeId") + .HasColumnType("text"); + + b.Property("SuperTypeNodeSetModelUri") + .HasColumnType("text"); + + b.Property("SuperTypeNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("SuperTypeNodeId", "SuperTypeNodeSetModelUri", "SuperTypeNodeSetPublicationDate"); + + b.ToTable("BaseTypes", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.MethodModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.NodeModel"); + + b.Property("ModellingRule") + .HasColumnType("text"); + + b.Property("ParentModelUri") + .HasColumnType("text"); + + b.Property("ParentNodeId") + .HasColumnType("text"); + + b.Property("ParentPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TypeDefinitionNodeId") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeSetModelUri") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("ParentNodeId", "ParentModelUri", "ParentPublicationDate"); + + b.HasIndex("TypeDefinitionNodeId", "TypeDefinitionNodeSetModelUri", "TypeDefinitionNodeSetPublicationDate"); + + b.ToTable("Methods", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.ObjectModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.NodeModel"); + + b.Property("ModellingRule") + .HasColumnType("text"); + + b.Property("NodeSetObjectsModelUri") + .HasColumnType("text"); + + b.Property("NodeSetObjectsPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentModelUri") + .HasColumnType("text"); + + b.Property("ParentNodeId") + .HasColumnType("text"); + + b.Property("ParentPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TypeDefinitionNodeId") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeSetModelUri") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("NodeSetObjectsModelUri", "NodeSetObjectsPublicationDate"); + + b.HasIndex("ParentNodeId", "ParentModelUri", "ParentPublicationDate"); + + b.HasIndex("TypeDefinitionNodeId", "TypeDefinitionNodeSetModelUri", "TypeDefinitionNodeSetPublicationDate"); + + b.ToTable("Objects", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.VariableModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.NodeModel"); + + b.Property("AccessLevel") + .HasColumnType("bigint"); + + b.Property("AccessRestrictions") + .HasColumnType("integer"); + + b.Property("ArrayDimensions") + .HasColumnType("text"); + + b.Property("DataTypeNodeId") + .HasColumnType("text"); + + b.Property("DataTypeNodeSetModelUri") + .HasColumnType("text"); + + b.Property("DataTypeNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EURangeAccessLevel") + .HasColumnType("bigint"); + + b.Property("EURangeModellingRule") + .HasColumnType("text"); + + b.Property("EURangeNodeId") + .HasColumnType("text"); + + b.Property("EngUnitAccessLevel") + .HasColumnType("bigint"); + + b.Property("EngUnitModellingRule") + .HasColumnType("text"); + + b.Property("EngUnitNodeId") + .HasColumnType("text"); + + b.Property("EnumValue") + .HasColumnType("bigint"); + + b.Property("InstrumentMaxValue") + .HasColumnType("double precision"); + + b.Property("InstrumentMinValue") + .HasColumnType("double precision"); + + b.Property("InstrumentRangeAccessLevel") + .HasColumnType("bigint"); + + b.Property("InstrumentRangeModellingRule") + .HasColumnType("text"); + + b.Property("InstrumentRangeNodeId") + .HasColumnType("text"); + + b.Property("MaxValue") + .HasColumnType("double precision"); + + b.Property("MinValue") + .HasColumnType("double precision"); + + b.Property("MinimumSamplingInterval") + .HasColumnType("double precision"); + + b.Property("ModellingRule") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeId") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeSetModelUri") + .HasColumnType("text"); + + b.Property("TypeDefinitionNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UserWriteMask") + .HasColumnType("bigint"); + + b.Property("Value") + .HasColumnType("text"); + + b.Property("ValueRank") + .HasColumnType("integer"); + + b.Property("WriteMask") + .HasColumnType("bigint"); + + b.HasIndex("DataTypeNodeId", "DataTypeNodeSetModelUri", "DataTypeNodeSetPublicationDate"); + + b.HasIndex("TypeDefinitionNodeId", "TypeDefinitionNodeSetModelUri", "TypeDefinitionNodeSetPublicationDate"); + + b.ToTable("Variables", (string)null); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.CloudLibNodeSetModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.NodeSetModel"); + + b.Property("LastModifiedDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidationElapsedTime") + .HasColumnType("interval"); + + b.Property("ValidationErrors") + .HasColumnType("text[]"); + + b.Property("ValidationFinishedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidationStatus") + .IsRequired() + .HasColumnType("text"); + + b.Property("ValidationStatusInfo") + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("CloudLibNodeSetModel"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.DataTypeModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.BaseTypeModel"); + + b.Property("IsOptionSet") + .HasColumnType("boolean"); + + b.Property("NodeSetDataTypesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetDataTypesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("NodeSetDataTypesModelUri", "NodeSetDataTypesPublicationDate"); + + b.ToTable("DataTypes", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.DataVariableModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.VariableModel"); + + b.Property("NodeSetDataVariablesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetDataVariablesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentModelUri") + .HasColumnType("text"); + + b.Property("ParentNodeId") + .HasColumnType("text"); + + b.Property("ParentPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("NodeSetDataVariablesModelUri", "NodeSetDataVariablesPublicationDate"); + + b.HasIndex("ParentNodeId", "ParentModelUri", "ParentPublicationDate"); + + b.ToTable("DataVariables", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.ObjectTypeModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.BaseTypeModel"); + + b.Property("NodeSetObjectTypesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetObjectTypesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("NodeSetObjectTypesModelUri", "NodeSetObjectTypesPublicationDate"); + + b.ToTable("ObjectTypes", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.PropertyModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.VariableModel"); + + b.Property("NodeSetPropertiesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetPropertiesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ParentModelUri") + .HasColumnType("text"); + + b.Property("ParentNodeId") + .HasColumnType("text"); + + b.Property("ParentPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("NodeSetPropertiesModelUri", "NodeSetPropertiesPublicationDate"); + + b.HasIndex("ParentNodeId", "ParentModelUri", "ParentPublicationDate"); + + b.ToTable("Properties", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.ReferenceTypeModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.BaseTypeModel"); + + b.Property("NodeSetReferenceTypesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetReferenceTypesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Symmetric") + .HasColumnType("boolean"); + + b.HasIndex("NodeSetReferenceTypesModelUri", "NodeSetReferenceTypesPublicationDate"); + + b.ToTable("ReferenceTypes", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.VariableTypeModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.BaseTypeModel"); + + b.Property("ArrayDimensions") + .HasColumnType("text"); + + b.Property("DataTypeNodeId") + .HasColumnType("text"); + + b.Property("DataTypeNodeSetModelUri") + .HasColumnType("text"); + + b.Property("DataTypeNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeSetVariableTypesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetVariableTypesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Value") + .HasColumnType("text"); + + b.Property("ValueRank") + .HasColumnType("integer"); + + b.HasIndex("NodeSetVariableTypesModelUri", "NodeSetVariableTypesPublicationDate"); + + b.HasIndex("DataTypeNodeId", "DataTypeNodeSetModelUri", "DataTypeNodeSetPublicationDate"); + + b.ToTable("VariableTypes", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.InterfaceModel", b => { + b.HasBaseType("CESMII.OpcUa.NodeSetModel.ObjectTypeModel"); + + b.Property("NodeSetInterfacesModelUri") + .HasColumnType("text"); + + b.Property("NodeSetInterfacesPublicationDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("NodeSetInterfacesModelUri", "NodeSetInterfacesPublicationDate"); + + b.ToTable("Interfaces", (string)null); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.NodeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", "NodeSet") + .WithMany() + .HasForeignKey("NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("UnknownNodes") + .HasForeignKey("NodeSetUnknownNodesModelUri", "NodeSetUnknownNodesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "Description", b1 => { + b1.Property("NodeModelNodeId") + .HasColumnType("text"); + + b1.Property("NodeModelNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("NodeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Locale") + .HasColumnType("text"); + + b1.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("NodeModelNodeId", "NodeModelNodeSetModelUri", "NodeModelNodeSetPublicationDate", "Id"); + + b1.ToTable("Nodes_Description"); + + b1.WithOwner() + .HasForeignKey("NodeModelNodeId", "NodeModelNodeSetModelUri", "NodeModelNodeSetPublicationDate"); + }); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "DisplayName", b1 => { + b1.Property("NodeModelNodeId") + .HasColumnType("text"); + + b1.Property("NodeModelNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("NodeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Locale") + .HasColumnType("text"); + + b1.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("NodeModelNodeId", "NodeModelNodeSetModelUri", "NodeModelNodeSetPublicationDate", "Id"); + + b1.ToTable("Nodes_DisplayName"); + + b1.WithOwner() + .HasForeignKey("NodeModelNodeId", "NodeModelNodeSetModelUri", "NodeModelNodeSetPublicationDate"); + }); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+NodeAndReference", "OtherReferencedNodes", b1 => { + b1.Property("OwnerNodeId") + .HasColumnType("text"); + + b1.Property("OwnerModelUri") + .HasColumnType("text"); + + b1.Property("OwnerPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Reference") + .HasColumnType("text"); + + b1.Property("ReferenceTypeModelUri") + .HasColumnType("text"); + + b1.Property("ReferenceTypeNodeId") + .HasColumnType("text"); + + b1.Property("ReferenceTypePublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("ReferencedModelUri") + .HasColumnType("text"); + + b1.Property("ReferencedNodeId") + .HasColumnType("text"); + + b1.Property("ReferencedPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("OwnerNodeId", "OwnerModelUri", "OwnerPublicationDate", "Id"); + + b1.HasIndex("ReferenceTypeNodeId", "ReferenceTypeModelUri", "ReferenceTypePublicationDate"); + + b1.HasIndex("ReferencedNodeId", "ReferencedModelUri", "ReferencedPublicationDate"); + + b1.ToTable("Nodes_OtherReferencedNodes"); + + b1.WithOwner() + .HasForeignKey("OwnerNodeId", "OwnerModelUri", "OwnerPublicationDate"); + + b1.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "ReferenceType") + .WithMany() + .HasForeignKey("ReferenceTypeNodeId", "ReferenceTypeModelUri", "ReferenceTypePublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b1.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "Node") + .WithMany() + .HasForeignKey("ReferencedNodeId", "ReferencedModelUri", "ReferencedPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b1.Navigation("Node"); + + b1.Navigation("ReferenceType"); + }); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+NodeAndReference", "OtherReferencingNodes", b1 => { + b1.Property("OwnerNodeId") + .HasColumnType("text"); + + b1.Property("OwnerModelUri") + .HasColumnType("text"); + + b1.Property("OwnerPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Reference") + .HasColumnType("text"); + + b1.Property("ReferenceTypeModelUri") + .HasColumnType("text"); + + b1.Property("ReferenceTypeNodeId") + .HasColumnType("text"); + + b1.Property("ReferenceTypePublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("ReferencingModelUri") + .HasColumnType("text"); + + b1.Property("ReferencingNodeId") + .HasColumnType("text"); + + b1.Property("ReferencingPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.HasKey("OwnerNodeId", "OwnerModelUri", "OwnerPublicationDate", "Id"); + + b1.HasIndex("ReferenceTypeNodeId", "ReferenceTypeModelUri", "ReferenceTypePublicationDate"); + + b1.HasIndex("ReferencingNodeId", "ReferencingModelUri", "ReferencingPublicationDate"); + + b1.ToTable("Nodes_OtherReferencingNodes"); + + b1.WithOwner() + .HasForeignKey("OwnerNodeId", "OwnerModelUri", "OwnerPublicationDate"); + + b1.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "ReferenceType") + .WithMany() + .HasForeignKey("ReferenceTypeNodeId", "ReferenceTypeModelUri", "ReferenceTypePublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b1.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "Node") + .WithMany() + .HasForeignKey("ReferencingNodeId", "ReferencingModelUri", "ReferencingPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b1.Navigation("Node"); + + b1.Navigation("ReferenceType"); + }); + + b.Navigation("Description"); + + b.Navigation("DisplayName"); + + b.Navigation("NodeSet"); + + b.Navigation("OtherReferencedNodes"); + + b.Navigation("OtherReferencingNodes"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.NodeSetModel", b => { + b.OwnsMany("CESMII.OpcUa.NodeSetModel.RequiredModelInfo", "RequiredModels", b1 => { + b1.Property("DependentModelUri") + .HasColumnType("text"); + + b1.Property("DependentPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("AvailableModelModelUri") + .HasColumnType("text"); + + b1.Property("AvailableModelPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("ModelUri") + .HasColumnType("text"); + + b1.Property("PublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Version") + .HasColumnType("text"); + + b1.HasKey("DependentModelUri", "DependentPublicationDate", "Id"); + + b1.HasIndex("AvailableModelModelUri", "AvailableModelPublicationDate"); + + b1.ToTable("RequiredModelInfo"); + + b1.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", "AvailableModel") + .WithMany() + .HasForeignKey("AvailableModelModelUri", "AvailableModelPublicationDate") + .OnDelete(DeleteBehavior.SetNull); + + b1.WithOwner() + .HasForeignKey("DependentModelUri", "DependentPublicationDate"); + + b1.Navigation("AvailableModel"); + }); + + b.Navigation("RequiredModels"); + }); + + modelBuilder.Entity("DataVariableModelNodeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.DataVariableModel", null) + .WithMany() + .HasForeignKey("DataVariablesNodeId", "DataVariablesNodeSetModelUri", "DataVariablesNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithMany() + .HasForeignKey("NodesWithDataVariablesNodeId", "NodesWithDataVariablesNodeSetModelUri", "NodesWithDataVariablesNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InterfaceModelNodeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.InterfaceModel", null) + .WithMany() + .HasForeignKey("InterfacesNodeId", "InterfacesNodeSetModelUri", "InterfacesNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithMany() + .HasForeignKey("NodesWithInterfaceNodeId", "NodesWithInterfaceNodeSetModelUri", "NodesWithInterfaceNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("MethodModelNodeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.MethodModel", null) + .WithMany() + .HasForeignKey("MethodsNodeId", "MethodsNodeSetModelUri", "MethodsNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithMany() + .HasForeignKey("NodesWithMethodsNodeId", "NodesWithMethodsNodeSetModelUri", "NodesWithMethodsNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeModelObjectModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithMany() + .HasForeignKey("NodesWithObjectsNodeId", "NodesWithObjectsNodeSetModelUri", "NodesWithObjectsNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.ObjectModel", null) + .WithMany() + .HasForeignKey("ObjectsNodeId", "ObjectsNodeSetModelUri", "ObjectsNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeModelObjectTypeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.ObjectTypeModel", null) + .WithMany() + .HasForeignKey("EventsNodeId", "EventsNodeSetModelUri", "EventsNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithMany() + .HasForeignKey("NodesWithEventsNodeId", "NodesWithEventsNodeSetModelUri", "NodesWithEventsNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("NodeModelVariableModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithMany() + .HasForeignKey("NodesWithPropertiesNodeId", "NodesWithPropertiesNodeSetModelUri", "NodesWithPropertiesNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.VariableModel", null) + .WithMany() + .HasForeignKey("PropertiesNodeId", "PropertiesNodeSetModelUri", "PropertiesNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DbContextModels.NamespaceMetaDataModel", b => { + b.HasOne("Opc.Ua.Cloud.Library.DbContextModels.CategoryModel", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Opc.Ua.Cloud.Library.DbContextModels.OrganisationModel", "Contributor") + .WithMany() + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("Opc.Ua.Cloud.Library.DbContextModels.AdditionalPropertyModel", "AdditionalProperties", b1 => { + b1.Property("NodeSetId") + .HasColumnType("text"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Name") + .HasColumnType("text"); + + b1.Property("Value") + .HasColumnType("text"); + + b1.HasKey("NodeSetId", "Id"); + + b1.ToTable("AdditionalProperties"); + + b1.WithOwner("NodeSet") + .HasForeignKey("NodeSetId"); + + b1.Navigation("NodeSet"); + }); + + b.Navigation("AdditionalProperties"); + + b.Navigation("Category"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.BaseTypeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.BaseTypeModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.BaseTypeModel", "SuperType") + .WithMany("SubTypes") + .HasForeignKey("SuperTypeNodeId", "SuperTypeNodeSetModelUri", "SuperTypeNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("SuperType"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.MethodModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.MethodModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "Parent") + .WithMany() + .HasForeignKey("ParentNodeId", "ParentModelUri", "ParentPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.MethodModel", "TypeDefinition") + .WithMany() + .HasForeignKey("TypeDefinitionNodeId", "TypeDefinitionNodeSetModelUri", "TypeDefinitionNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + + b.Navigation("TypeDefinition"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.ObjectModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("Objects") + .HasForeignKey("NodeSetObjectsModelUri", "NodeSetObjectsPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.ObjectModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "Parent") + .WithMany() + .HasForeignKey("ParentNodeId", "ParentModelUri", "ParentPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.ObjectTypeModel", "TypeDefinition") + .WithMany() + .HasForeignKey("TypeDefinitionNodeId", "TypeDefinitionNodeSetModelUri", "TypeDefinitionNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + + b.Navigation("TypeDefinition"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.VariableModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.DataTypeModel", "DataType") + .WithMany() + .HasForeignKey("DataTypeNodeId", "DataTypeNodeSetModelUri", "DataTypeNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.VariableModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.VariableTypeModel", "TypeDefinition") + .WithMany() + .HasForeignKey("TypeDefinitionNodeId", "TypeDefinitionNodeSetModelUri", "TypeDefinitionNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.OwnsOne("CESMII.OpcUa.NodeSetModel.VariableModel+EngineeringUnitInfo", "EngineeringUnit", b1 => { + b1.Property("VariableModelNodeId") + .HasColumnType("text"); + + b1.Property("VariableModelNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("VariableModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("NamespaceUri") + .IsRequired() + .HasColumnType("text"); + + b1.Property("UnitId") + .HasColumnType("integer"); + + b1.HasKey("VariableModelNodeId", "VariableModelNodeSetModelUri", "VariableModelNodeSetPublicationDate"); + + b1.ToTable("Variables"); + + b1.WithOwner() + .HasForeignKey("VariableModelNodeId", "VariableModelNodeSetModelUri", "VariableModelNodeSetPublicationDate"); + + b1.OwnsOne("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "Description", b2 => { + b2.Property("EngineeringUnitInfoVariableModelNodeId") + .HasColumnType("text"); + + b2.Property("EngineeringUnitInfoVariableModelNodeSetModelUri") + .HasColumnType("text"); + + b2.Property("EngineeringUnitInfoVariableModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b2.Property("Locale") + .HasColumnType("text"); + + b2.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("EngineeringUnitInfoVariableModelNodeId", "EngineeringUnitInfoVariableModelNodeSetModelUri", "EngineeringUnitInfoVariableModelNodeSetPublicationDate"); + + b2.ToTable("Variables"); + + b2.WithOwner() + .HasForeignKey("EngineeringUnitInfoVariableModelNodeId", "EngineeringUnitInfoVariableModelNodeSetModelUri", "EngineeringUnitInfoVariableModelNodeSetPublicationDate"); + }); + + b1.OwnsOne("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "DisplayName", b2 => { + b2.Property("EngineeringUnitInfoVariableModelNodeId") + .HasColumnType("text"); + + b2.Property("EngineeringUnitInfoVariableModelNodeSetModelUri") + .HasColumnType("text"); + + b2.Property("EngineeringUnitInfoVariableModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b2.Property("Locale") + .HasColumnType("text"); + + b2.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("EngineeringUnitInfoVariableModelNodeId", "EngineeringUnitInfoVariableModelNodeSetModelUri", "EngineeringUnitInfoVariableModelNodeSetPublicationDate"); + + b2.ToTable("Variables"); + + b2.WithOwner() + .HasForeignKey("EngineeringUnitInfoVariableModelNodeId", "EngineeringUnitInfoVariableModelNodeSetModelUri", "EngineeringUnitInfoVariableModelNodeSetPublicationDate"); + }); + + b1.Navigation("Description"); + + b1.Navigation("DisplayName"); + }); + + b.Navigation("DataType"); + + b.Navigation("EngineeringUnit"); + + b.Navigation("TypeDefinition"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.CloudLibNodeSetModel", b => { + b.HasOne("Opc.Ua.Cloud.Library.DbContextModels.NamespaceMetaDataModel", "Metadata") + .WithOne("NodeSet") + .HasForeignKey("Opc.Ua.Cloud.Library.CloudLibNodeSetModel", "Identifier") + .OnDelete(DeleteBehavior.NoAction); + + b.Navigation("Metadata"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.DataTypeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("DataTypes") + .HasForeignKey("NodeSetDataTypesModelUri", "NodeSetDataTypesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.BaseTypeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.DataTypeModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.DataTypeModel+StructureField", "StructureFields", b1 => { + b1.Property("DataTypeModelNodeId") + .HasColumnType("text"); + + b1.Property("DataTypeModelNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("DataTypeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("ArrayDimensions") + .HasColumnType("text"); + + b1.Property("DataTypeNodeId") + .HasColumnType("text"); + + b1.Property("DataTypeNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("DataTypeNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("FieldOrder") + .HasColumnType("integer"); + + b1.Property("IsOptional") + .HasColumnType("boolean"); + + b1.Property("MaxStringLength") + .HasColumnType("bigint"); + + b1.Property("Name") + .HasColumnType("text"); + + b1.Property("ValueRank") + .HasColumnType("integer"); + + b1.HasKey("DataTypeModelNodeId", "DataTypeModelNodeSetModelUri", "DataTypeModelNodeSetPublicationDate", "Id"); + + b1.HasIndex("DataTypeNodeId", "DataTypeNodeSetModelUri", "DataTypeNodeSetPublicationDate"); + + b1.ToTable("StructureField"); + + b1.WithOwner() + .HasForeignKey("DataTypeModelNodeId", "DataTypeModelNodeSetModelUri", "DataTypeModelNodeSetPublicationDate"); + + b1.HasOne("CESMII.OpcUa.NodeSetModel.BaseTypeModel", "DataType") + .WithMany() + .HasForeignKey("DataTypeNodeId", "DataTypeNodeSetModelUri", "DataTypeNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b1.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "Description", b2 => { + b2.Property("StructureFieldDataTypeModelNodeId") + .HasColumnType("text"); + + b2.Property("StructureFieldDataTypeModelNodeSetModelUri") + .HasColumnType("text"); + + b2.Property("StructureFieldDataTypeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b2.Property("StructureFieldId") + .HasColumnType("integer"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b2.Property("Id")); + + b2.Property("Locale") + .HasColumnType("text"); + + b2.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("StructureFieldDataTypeModelNodeId", "StructureFieldDataTypeModelNodeSetModelUri", "StructureFieldDataTypeModelNodeSetPublicationDate", "StructureFieldId", "Id"); + + b2.ToTable("StructureField_Description"); + + b2.WithOwner() + .HasForeignKey("StructureFieldDataTypeModelNodeId", "StructureFieldDataTypeModelNodeSetModelUri", "StructureFieldDataTypeModelNodeSetPublicationDate", "StructureFieldId"); + }); + + b1.Navigation("DataType"); + + b1.Navigation("Description"); + }); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.DataTypeModel+UaEnumField", "EnumFields", b1 => { + b1.Property("DataTypeModelNodeId") + .HasColumnType("text"); + + b1.Property("DataTypeModelNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("DataTypeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Name") + .HasColumnType("text"); + + b1.Property("Value") + .HasColumnType("bigint"); + + b1.HasKey("DataTypeModelNodeId", "DataTypeModelNodeSetModelUri", "DataTypeModelNodeSetPublicationDate", "Id"); + + b1.ToTable("UaEnumField"); + + b1.WithOwner() + .HasForeignKey("DataTypeModelNodeId", "DataTypeModelNodeSetModelUri", "DataTypeModelNodeSetPublicationDate"); + + b1.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "Description", b2 => { + b2.Property("UaEnumFieldDataTypeModelNodeId") + .HasColumnType("text"); + + b2.Property("UaEnumFieldDataTypeModelNodeSetModelUri") + .HasColumnType("text"); + + b2.Property("UaEnumFieldDataTypeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b2.Property("UaEnumFieldId") + .HasColumnType("integer"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b2.Property("Id")); + + b2.Property("Locale") + .HasColumnType("text"); + + b2.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("UaEnumFieldDataTypeModelNodeId", "UaEnumFieldDataTypeModelNodeSetModelUri", "UaEnumFieldDataTypeModelNodeSetPublicationDate", "UaEnumFieldId", "Id"); + + b2.ToTable("UaEnumField_Description"); + + b2.WithOwner() + .HasForeignKey("UaEnumFieldDataTypeModelNodeId", "UaEnumFieldDataTypeModelNodeSetModelUri", "UaEnumFieldDataTypeModelNodeSetPublicationDate", "UaEnumFieldId"); + }); + + b1.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "DisplayName", b2 => { + b2.Property("UaEnumFieldDataTypeModelNodeId") + .HasColumnType("text"); + + b2.Property("UaEnumFieldDataTypeModelNodeSetModelUri") + .HasColumnType("text"); + + b2.Property("UaEnumFieldDataTypeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b2.Property("UaEnumFieldId") + .HasColumnType("integer"); + + b2.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b2.Property("Id")); + + b2.Property("Locale") + .HasColumnType("text"); + + b2.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b2.HasKey("UaEnumFieldDataTypeModelNodeId", "UaEnumFieldDataTypeModelNodeSetModelUri", "UaEnumFieldDataTypeModelNodeSetPublicationDate", "UaEnumFieldId", "Id"); + + b2.ToTable("UaEnumField_DisplayName"); + + b2.WithOwner() + .HasForeignKey("UaEnumFieldDataTypeModelNodeId", "UaEnumFieldDataTypeModelNodeSetModelUri", "UaEnumFieldDataTypeModelNodeSetPublicationDate", "UaEnumFieldId"); + }); + + b1.Navigation("Description"); + + b1.Navigation("DisplayName"); + }); + + b.Navigation("EnumFields"); + + b.Navigation("StructureFields"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.DataVariableModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("DataVariables") + .HasForeignKey("NodeSetDataVariablesModelUri", "NodeSetDataVariablesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.VariableModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.DataVariableModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "Parent") + .WithMany() + .HasForeignKey("ParentNodeId", "ParentModelUri", "ParentPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.ObjectTypeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("ObjectTypes") + .HasForeignKey("NodeSetObjectTypesModelUri", "NodeSetObjectTypesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.BaseTypeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.ObjectTypeModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.PropertyModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("Properties") + .HasForeignKey("NodeSetPropertiesModelUri", "NodeSetPropertiesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.VariableModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.PropertyModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeModel", "Parent") + .WithMany() + .HasForeignKey("ParentNodeId", "ParentModelUri", "ParentPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.ReferenceTypeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("ReferenceTypes") + .HasForeignKey("NodeSetReferenceTypesModelUri", "NodeSetReferenceTypesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.BaseTypeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.ReferenceTypeModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("CESMII.OpcUa.NodeSetModel.NodeModel+LocalizedText", "InverseName", b1 => { + b1.Property("ReferenceTypeModelNodeId") + .HasColumnType("text"); + + b1.Property("ReferenceTypeModelNodeSetModelUri") + .HasColumnType("text"); + + b1.Property("ReferenceTypeModelNodeSetPublicationDate") + .HasColumnType("timestamp with time zone"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property("Id")); + + b1.Property("Locale") + .HasColumnType("text"); + + b1.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("ReferenceTypeModelNodeId", "ReferenceTypeModelNodeSetModelUri", "ReferenceTypeModelNodeSetPublicationDate", "Id"); + + b1.ToTable("ReferenceTypes_InverseName"); + + b1.WithOwner() + .HasForeignKey("ReferenceTypeModelNodeId", "ReferenceTypeModelNodeSetModelUri", "ReferenceTypeModelNodeSetPublicationDate"); + }); + + b.Navigation("InverseName"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.VariableTypeModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("VariableTypes") + .HasForeignKey("NodeSetVariableTypesModelUri", "NodeSetVariableTypesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.DataTypeModel", "DataType") + .WithMany() + .HasForeignKey("DataTypeNodeId", "DataTypeNodeSetModelUri", "DataTypeNodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.BaseTypeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.VariableTypeModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DataType"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.InterfaceModel", b => { + b.HasOne("CESMII.OpcUa.NodeSetModel.NodeSetModel", null) + .WithMany("Interfaces") + .HasForeignKey("NodeSetInterfacesModelUri", "NodeSetInterfacesPublicationDate") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("CESMII.OpcUa.NodeSetModel.ObjectTypeModel", null) + .WithOne() + .HasForeignKey("CESMII.OpcUa.NodeSetModel.InterfaceModel", "NodeId", "NodeSetModelUri", "NodeSetPublicationDate") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.NodeSetModel", b => { + b.Navigation("DataTypes"); + + b.Navigation("DataVariables"); + + b.Navigation("Interfaces"); + + b.Navigation("ObjectTypes"); + + b.Navigation("Objects"); + + b.Navigation("Properties"); + + b.Navigation("ReferenceTypes"); + + b.Navigation("UnknownNodes"); + + b.Navigation("VariableTypes"); + }); + + modelBuilder.Entity("Opc.Ua.Cloud.Library.DbContextModels.NamespaceMetaDataModel", b => { + b.Navigation("NodeSet"); + }); + + modelBuilder.Entity("CESMII.OpcUa.NodeSetModel.BaseTypeModel", b => { + b.Navigation("SubTypes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/UACloudLibraryServer/Migrations/20230608191754_creationtime.cs b/UACloudLibraryServer/Migrations/20230608191754_creationtime.cs new file mode 100644 index 00000000..2472fa21 --- /dev/null +++ b/UACloudLibraryServer/Migrations/20230608191754_creationtime.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Opc.Ua.Cloud.Library +{ + public partial class creationtime : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreationTime", + table: "NamespaceMeta", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreationTime", + table: "NamespaceMeta"); + } + } +} diff --git a/UACloudLibraryServer/Migrations/ApplicationDbContextModelSnapshot.cs b/UACloudLibraryServer/Migrations/ApplicationDbContextModelSnapshot.cs index f43d9e76..4ba76677 100644 --- a/UACloudLibraryServer/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/UACloudLibraryServer/Migrations/ApplicationDbContextModelSnapshot.cs @@ -515,6 +515,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CopyrightText") .HasColumnType("text"); + b.Property("CreationTime") + .HasColumnType("timestamp with time zone"); + b.Property("Description") .HasColumnType("text"); diff --git a/UACloudLibraryServer/UA-CloudLibrary.csproj b/UACloudLibraryServer/UA-CloudLibrary.csproj index c2a07848..2515c8a0 100644 --- a/UACloudLibraryServer/UA-CloudLibrary.csproj +++ b/UACloudLibraryServer/UA-CloudLibrary.csproj @@ -48,10 +48,10 @@ - - - - + + + +
From 94dffd9cca43c7988af3ccc2992dd614a6e0c317 Mon Sep 17 00:00:00 2001 From: Erich Barnstedt Date: Tue, 18 Jul 2023 20:07:50 +0200 Subject: [PATCH 2/8] Update OPC UA stack Nugets only (#184) * Rename addressspace -> namespace * Disable logerror warning. * Fix using Async in method names. * Implement disposeable on postgresql implementation. * bug fix. * Bug and code analyzer warning fixes and fix naming of cloudlibclient variables. * Fix regression. * Fix regression. * Fix formatting. * fix formatting. * fix formatting. * fix formatting. * Fix formatting. * Added docker compose back into solution file and deleted extra solution file. * Fix one more (misspelt) addressspace -> namespace rename. * Remove dups from category and org queries. * Remove unneeded using. * Fix cut and paste error. * Fix client lib. * Property name change from nodesetCreationTime to publicationDate to be consistent. * Fix for bug https://github.com/OPCFoundation/UA-CloudLibrary/issues/90 * Fixes security CVE-2022-30187 and maintain stack info on exception. * Fix build break and CVEs. * Added Azure Data Protection Key per instance and updated NuGets. * Fix version string. * Fix CloudLib Explorer exception and many async and dependency injection-related issues. * update version to match spec. * Fix usings/whitespace. * Move QueryModel to GraphQL folder. * Switch to Postmark as Sendgrid disabled our account and was unreliable anyway. * Bring sendgrid back in optionally. * Added description for data protection env variable. * Fix CVE and update other NuGets, too. * Switch to localhost for DB endpoint and add missing env variable. * Re-enabling builsing the sync tool. * NodeSetModel 1.0.13 plus migrations * File encoding fix * Update to latest OPC UA stack to avoid security issue. --------- Co-authored-by: Markus Horstmann --- CloudLibSync/CloudLibSync.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CloudLibSync/CloudLibSync.csproj b/CloudLibSync/CloudLibSync.csproj index 4d357708..ed505bfe 100644 --- a/CloudLibSync/CloudLibSync.csproj +++ b/CloudLibSync/CloudLibSync.csproj @@ -9,7 +9,7 @@ - + From 71d3fdf60b8623b830af24198d98e038028fb42d Mon Sep 17 00:00:00 2001 From: Erich Barnstedt Date: Tue, 19 Sep 2023 20:50:39 +0200 Subject: [PATCH 3/8] OAuth2 support, better user account/email management support and code hardening. (#188) * Rename addressspace -> namespace * Disable logerror warning. * Fix using Async in method names. * Implement disposeable on postgresql implementation. * bug fix. * Bug and code analyzer warning fixes and fix naming of cloudlibclient variables. * Fix regression. * Fix regression. * Fix formatting. * fix formatting. * fix formatting. * fix formatting. * Fix formatting. * Added docker compose back into solution file and deleted extra solution file. * Fix one more (misspelt) addressspace -> namespace rename. * Remove dups from category and org queries. * Remove unneeded using. * Fix cut and paste error. * Fix client lib. * Property name change from nodesetCreationTime to publicationDate to be consistent. * Fix for bug https://github.com/OPCFoundation/UA-CloudLibrary/issues/90 * Fixes security CVE-2022-30187 and maintain stack info on exception. * Fix build break and CVEs. * Added Azure Data Protection Key per instance and updated NuGets. * Fix version string. * Fix CloudLib Explorer exception and many async and dependency injection-related issues. * update version to match spec. * Fix usings/whitespace. * Move QueryModel to GraphQL folder. * Switch to Postmark as Sendgrid disabled our account and was unreliable anyway. * Bring sendgrid back in optionally. * Added description for data protection env variable. * Fix CVE and update other NuGets, too. * Switch to localhost for DB endpoint and add missing env variable. * Re-enabling builsing the sync tool. * NodeSetModel 1.0.13 plus migrations * File encoding fix * Update to latest OPC UA stack to avoid security issue. * 1. Add OAuth2 auth via OPC Foundation website and additional login UI from ASP.NetCore Identity Scafolding. 2. Add missing ConfigureAwaits. 3. Simplify auth in Swagger. 4. Add account management UI from ASP.NetCore Identity Scafolding. 5. Add email confirmation management from ASP.NetCore Identity Scafolding. * Remove unnecessary usings. * Test: add oauth2 config --------- Co-authored-by: Markus Horstmann --- CloudLibSync/Program.cs | 2 +- .../CloudLibIntegrationTest.cs | 8 +- .../CustomWebApplicationFactory.cs | 3 + .../CloudLibClientTests/QueriesAndDownload.cs | 11 +- Tests/CloudLibClientTests/UploadAndIndex.cs | 2 +- .../Pages/Account/AccessDenied.cshtml | 10 + .../Pages/Account/AccessDenied.cshtml.cs | 23 ++ .../Pages/Account/ConfirmEmail.cshtml | 8 + .../Pages/Account/ConfirmEmail.cshtml.cs | 48 ++++ .../Pages/Account/ConfirmEmailChange.cshtml | 8 + .../Account/ConfirmEmailChange.cshtml.cs | 67 ++++++ .../Pages/Account/ExternalLogin.cshtml | 33 +++ .../Pages/Account/ExternalLogin.cshtml.cs | 222 ++++++++++++++++++ .../Pages/Account/ForgotPassword.cshtml | 26 ++ .../Pages/Account/ForgotPassword.cshtml.cs | 82 +++++++ .../Account/ForgotPasswordConfirmation.cshtml | 10 + .../ForgotPasswordConfirmation.cshtml.cs | 25 ++ .../Identity/Pages/Account/Lockout.cshtml | 10 + .../Identity/Pages/Account/Lockout.cshtml.cs | 25 ++ .../Areas/Identity/Pages/Account/Login.cshtml | 68 ++++-- .../Identity/Pages/Account/Login.cshtml.cs | 59 +++-- .../Identity/Pages/Account/Logout.cshtml | 21 ++ .../Identity/Pages/Account/Logout.cshtml.cs | 40 ++++ .../Account/Manage/ChangePassword.cshtml | 36 +++ .../Account/Manage/ChangePassword.cshtml.cs | 126 ++++++++++ .../Account/Manage/DeletePersonalData.cshtml | 33 +++ .../Manage/DeletePersonalData.cshtml.cs | 103 ++++++++ .../Manage/DownloadPersonalData.cshtml | 12 + .../Manage/DownloadPersonalData.cshtml.cs | 66 ++++++ .../Pages/Account/Manage/Email.cshtml | 44 ++++ .../Pages/Account/Manage/Email.cshtml.cs | 170 ++++++++++++++ .../Account/Manage/ExternalLogins.cshtml | 53 +++++ .../Account/Manage/ExternalLogins.cshtml.cs | 140 +++++++++++ .../Pages/Account/Manage/Index.cshtml | 30 +++ .../Pages/Account/Manage/Index.cshtml.cs | 116 +++++++++ .../Pages/Account/Manage/ManageNavPages.cs | 123 ++++++++++ .../Pages/Account/Manage/PersonalData.cshtml | 27 +++ .../Account/Manage/PersonalData.cshtml.cs | 36 +++ .../Pages/Account/Manage/SetPassword.cshtml | 35 +++ .../Account/Manage/SetPassword.cshtml.cs | 113 +++++++++ .../Pages/Account/Manage/_Layout.cshtml | 29 +++ .../Pages/Account/Manage/_ManageNav.cshtml | 14 ++ .../Account/Manage/_StatusMessage.cshtml | 10 + .../Pages/Account/Manage/_ViewImports.cshtml | 1 + .../Identity/Pages/Account/Register.cshtml | 50 +++- .../Identity/Pages/Account/Register.cshtml.cs | 87 ++++++- .../Pages/Account/RegisterConfirmation.cshtml | 22 ++ .../Account/RegisterConfirmation.cshtml.cs | 78 ++++++ .../Account/ResendEmailConfirmation.cshtml | 26 ++ .../Account/ResendEmailConfirmation.cshtml.cs | 87 +++++++ .../Pages/Account/ResetPassword.cshtml | 37 +++ .../Pages/Account/ResetPassword.cshtml.cs | 115 +++++++++ .../Account/ResetPasswordConfirmation.cshtml | 10 + .../ResetPasswordConfirmation.cshtml.cs | 25 ++ .../Pages/Account/_StatusMessage.cshtml | 10 + .../Pages/_ValidationScriptsPartial.cshtml | 6 +- .../BasicAuthenticationHandler.cs | 3 + UACloudLibraryServer/CloudLibDataProvider.cs | 12 +- .../Controllers/ApprovalController.cs | 8 +- .../Controllers/ExplorerController.cs | 35 ++- .../Controllers/InfoModelController.cs | 7 +- UACloudLibraryServer/GraphQL/ApprovalModel.cs | 2 +- .../NodeSetIndex/NodeSetModelIndexer.cs | 6 +- UACloudLibraryServer/Startup.cs | 57 ++++- UACloudLibraryServer/UserService.cs | 4 +- 65 files changed, 2712 insertions(+), 103 deletions(-) create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_Layout.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs create mode 100644 UACloudLibraryServer/Areas/Identity/Pages/Account/_StatusMessage.cshtml diff --git a/CloudLibSync/Program.cs b/CloudLibSync/Program.cs index c3f2dd05..5ad370d0 100644 --- a/CloudLibSync/Program.cs +++ b/CloudLibSync/Program.cs @@ -51,7 +51,7 @@ public async Task MainAsync(string[] args) uploadCommand, }; - await root.InvokeAsync(args); + await root.InvokeAsync(args).ConfigureAwait(false); return 0; } diff --git a/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs b/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs index 8dcdf871..32a7cb7b 100644 --- a/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs +++ b/Tests/CloudLibClientTests/CloudLibIntegrationTest.cs @@ -32,7 +32,7 @@ public async Task Search(string[] keywords, int expectedCount) { var apiClient = _factory.CreateCloudLibClient(); - var nodeSets = await PagedVsNonPagedAsync(apiClient, keywords: keywords, after: null, first: 100); + var nodeSets = await PagedVsNonPagedAsync(apiClient, keywords: keywords, after: null, first: 100).ConfigureAwait(false); output.WriteLine($"{nodeSets.Count}"); // Ignore namespace versions for now: the only duplicate version is the test namespace generated by the QueriesAndDownload.UpdateNodeSet test, which may run before or after this test Assert.Equal(expectedCount, nodeSets.DistinctBy(n => n.NamespaceUri.OriginalString).Count()); @@ -40,10 +40,10 @@ public async Task Search(string[] keywords, int expectedCount) private async Task> PagedVsNonPagedAsync(UACloudLibClient apiClient, string[] keywords, string after, int first) { - var unpagedResult = await apiClient.GetNodeSetsAsync(keywords: keywords, after: after, first: first); + var unpagedResult = await apiClient.GetNodeSetsAsync(keywords: keywords, after: after, first: first).ConfigureAwait(false); var unpaged = unpagedResult.Edges.Select(e => e.Node).ToList(); - List paged = await GetAllPaged(apiClient, keywords: keywords, after: after, first: 5); + List paged = await GetAllPaged(apiClient, keywords: keywords, after: after, first: 5).ConfigureAwait(false); Assert.True(paged.Count == unpaged.Count); Assert.Equal(unpaged, paged/*.Take(cloud.Count)*/, new NodesetComparer(output)); @@ -60,7 +60,7 @@ private static async Task> GetAllPaged(UACloudLibClient apiClient, string cursor = after; do { - var page = await apiClient.GetNodeSetsAsync(keywords: keywords, after: cursor, first: first); + var page = await apiClient.GetNodeSetsAsync(keywords: keywords, after: cursor, first: first).ConfigureAwait(false); Assert.True(page.Edges.Count <= first, "CloudLibAsync returned more profiles than requested"); paged.AddRange(page.Edges.Select(e => e.Node)); if (!page.PageInfo.HasNextPage) diff --git a/Tests/CloudLibClientTests/CustomWebApplicationFactory.cs b/Tests/CloudLibClientTests/CustomWebApplicationFactory.cs index 41c89ad3..fc2e320b 100644 --- a/Tests/CloudLibClientTests/CustomWebApplicationFactory.cs +++ b/Tests/CloudLibClientTests/CustomWebApplicationFactory.cs @@ -46,6 +46,9 @@ protected override IHostBuilder CreateHostBuilder() { "ServicePassword", "testpw" }, { "ConnectionStrings:CloudLibraryPostgreSQL", "Server=localhost;Username=testuser;Database=cloudlib_test;Port=5432;Password=password;SSLMode=Prefer;Include Error Detail=true" }, { "CloudLibrary:ApprovalRequired", "false" }, + { "OAuth2ClientId", "Test" }, + { "OAuth2ClientSecret", "TestSecret" } + })) ; } diff --git a/Tests/CloudLibClientTests/QueriesAndDownload.cs b/Tests/CloudLibClientTests/QueriesAndDownload.cs index bebdb29c..ed1a9e3a 100644 --- a/Tests/CloudLibClientTests/QueriesAndDownload.cs +++ b/Tests/CloudLibClientTests/QueriesAndDownload.cs @@ -261,7 +261,7 @@ public async Task GetNodeSetsAsync(bool noMetadata, bool noRequiredModels, bool int? totalCount = null; do { - var result = await client.GetNodeSetsAsync(after: cursor, first: limit, noRequiredModels: noRequiredModels, noMetadata: noMetadata, noTotalCount: noTotalCount, noCreationTime: noCreationTime); + var result = await client.GetNodeSetsAsync(after: cursor, first: limit, noRequiredModels: noRequiredModels, noMetadata: noMetadata, noTotalCount: noTotalCount, noCreationTime: noCreationTime).ConfigureAwait(false); Assert.True(cursor == null || result.Edges?.Count > 0, "Failed to get node set information."); testNodeSet = result.Edges.FirstOrDefault(n => n.Node.NamespaceUri.OriginalString == strTestNamespaceUri)?.Node; @@ -415,7 +415,7 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic { output.WriteLine($"Uploaded {addressSpace?.Nodeset.NamespaceUri}, {addressSpace?.Nodeset.Identifier}"); var uploadedIdentifier = response.Message; - var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null); + var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null).ConfigureAwait(false); Assert.NotNull(approvalResult); Assert.Equal("APPROVED", approvalResult.ApprovalStatus); } @@ -452,14 +452,14 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic await Task.Delay(5000); } } while (notIndexed); - await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount); + await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount).ConfigureAwait(false); // Upload with override response = await client.UploadNodeSetAsync(addressSpace, true).ConfigureAwait(false); Assert.Equal(HttpStatusCode.OK, response.Status); { var uploadedIdentifier = response.Message; - var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null); + var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null).ConfigureAwait(false); Assert.NotNull(approvalResult); Assert.Equal("APPROVED", approvalResult.ApprovalStatus); } @@ -473,7 +473,8 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic await Task.Delay(5000); } } while (notIndexed); - await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount); + + await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount).ConfigureAwait(false); } } } diff --git a/Tests/CloudLibClientTests/UploadAndIndex.cs b/Tests/CloudLibClientTests/UploadAndIndex.cs index e2a07767..20be1377 100644 --- a/Tests/CloudLibClientTests/UploadAndIndex.cs +++ b/Tests/CloudLibClientTests/UploadAndIndex.cs @@ -41,7 +41,7 @@ public async Task UploadNodeSets(string fileName) { output.WriteLine($"Uploaded {addressSpace?.Nodeset.NamespaceUri}, {addressSpace?.Nodeset.Identifier}"); var uploadedIdentifier = response.Message; - var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null); + var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null).ConfigureAwait(false); Assert.NotNull(approvalResult); Assert.Equal("APPROVED", approvalResult.ApprovalStatus); } diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml new file mode 100644 index 00000000..abea8218 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml @@ -0,0 +1,10 @@ +@page +@model AccessDeniedModel +@{ + ViewData["Title"] = "Access denied"; +} + +
+

@ViewData["Title"]

+

You do not have access to this resource.

+
diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs new file mode 100644 index 00000000..926969ea --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/AccessDenied.cshtml.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class AccessDeniedModel : PageModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public void OnGet() + { + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml new file mode 100644 index 00000000..5be0410c --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml @@ -0,0 +1,8 @@ +@page +@model ConfirmEmailModel +@{ + ViewData["Title"] = "Confirm email"; +} + +

@ViewData["Title"]

+ diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs new file mode 100644 index 00000000..f189611b --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs @@ -0,0 +1,48 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + public class ConfirmEmailModel : PageModel + { + private readonly UserManager _userManager; + + public ConfirmEmailModel(UserManager userManager) + { + _userManager = userManager; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + public async Task OnGetAsync(string userId, string code) + { + if (userId == null || code == null) + { + return RedirectToPage("/Index"); + } + + var user = await _userManager.FindByIdAsync(userId).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{userId}'."); + } + + code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await _userManager.ConfirmEmailAsync(user, code).ConfigureAwait(false); + StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email."; + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml new file mode 100644 index 00000000..190601c6 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml @@ -0,0 +1,8 @@ +@page +@model ConfirmEmailChangeModel +@{ + ViewData["Title"] = "Confirm email change"; +} + +

@ViewData["Title"]

+ diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs new file mode 100644 index 00000000..3693b6c1 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs @@ -0,0 +1,67 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + public class ConfirmEmailChangeModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public ConfirmEmailChangeModel(UserManager userManager, SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGetAsync(string userId, string email, string code) + { + if (userId == null || email == null || code == null) + { + return RedirectToPage("/Index"); + } + + var user = await _userManager.FindByIdAsync(userId).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{userId}'."); + } + + code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var result = await _userManager.ChangeEmailAsync(user, email, code).ConfigureAwait(false); + if (!result.Succeeded) + { + StatusMessage = "Error changing email."; + return Page(); + } + + // In our UI email and user name are one and the same, so when we update the email + // we need to update the user name. + var setUserNameResult = await _userManager.SetUserNameAsync(user, email).ConfigureAwait(false); + if (!setUserNameResult.Succeeded) + { + StatusMessage = "Error changing user name."; + return Page(); + } + + await _signInManager.RefreshSignInAsync(user).ConfigureAwait(false); + StatusMessage = "Thank you for confirming your email change."; + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml new file mode 100644 index 00000000..76620e8a --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml @@ -0,0 +1,33 @@ +@page +@model ExternalLoginModel +@{ + ViewData["Title"] = "Register"; +} + +

@ViewData["Title"]

+

Associate your @Model.ProviderDisplayName account.

+
+ +

+ You've successfully authenticated with @Model.ProviderDisplayName. + Please enter an email address for this site below and click the Register button to finish + logging in. +

+ +
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs new file mode 100644 index 00000000..a7c5eb2d --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs @@ -0,0 +1,222 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System; +using System.ComponentModel.DataAnnotations; +using System.Security.Claims; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class ExternalLoginModel : PageModel + { + private readonly SignInManager _signInManager; + private readonly UserManager _userManager; + private readonly IUserStore _userStore; + private readonly IUserEmailStore _emailStore; + private readonly IEmailSender _emailSender; + private readonly ILogger _logger; + + public ExternalLoginModel( + SignInManager signInManager, + UserManager userManager, + IUserStore userStore, + ILogger logger, + IEmailSender emailSender) + { + _signInManager = signInManager; + _userManager = userManager; + _userStore = userStore; + _emailStore = GetEmailStore(); + _logger = logger; + _emailSender = emailSender; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string ProviderDisplayName { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string ReturnUrl { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string ErrorMessage { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + public string Email { get; set; } + } + + public IActionResult OnGet() => RedirectToPage("./Login"); + + public IActionResult OnPost(string provider, string returnUrl = null) + { + // Request a redirect to the external login provider. + var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl }); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl); + return new ChallengeResult(provider, properties); + } + + public async Task OnGetCallbackAsync(string returnUrl = null, string remoteError = null) + { + returnUrl = returnUrl ?? Url.Content("~/"); + if (remoteError != null) + { + ErrorMessage = $"Error from external provider: {remoteError}"; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + var info = await _signInManager.GetExternalLoginInfoAsync().ConfigureAwait(false); + if (info == null) + { + ErrorMessage = "Error loading external login information."; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + + // Sign in the user with this external login provider if the user already has a login. + var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true).ConfigureAwait(false); + if (result.Succeeded) + { + _logger.LogInformation("{Name} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider); + return LocalRedirect(returnUrl); + } + if (result.IsLockedOut) + { + return RedirectToPage("./Lockout"); + } + else + { + // If the user does not have an account, then ask the user to create an account. + ReturnUrl = returnUrl; + ProviderDisplayName = info.ProviderDisplayName; + if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email)) + { + Input = new InputModel + { + Email = info.Principal.FindFirstValue(ClaimTypes.Email) + }; + } + return Page(); + } + } + + public async Task OnPostConfirmationAsync(string returnUrl = null) + { + returnUrl = returnUrl ?? Url.Content("~/"); + // Get the information about the user from the external login provider + var info = await _signInManager.GetExternalLoginInfoAsync().ConfigureAwait(false); + if (info == null) + { + ErrorMessage = "Error loading external login information during confirmation."; + return RedirectToPage("./Login", new { ReturnUrl = returnUrl }); + } + + if (ModelState.IsValid) + { + var user = CreateUser(); + + await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None).ConfigureAwait(false); + await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None).ConfigureAwait(false); + + var result = await _userManager.CreateAsync(user).ConfigureAwait(false); + if (result.Succeeded) + { + result = await _userManager.AddLoginAsync(user, info).ConfigureAwait(false); + if (result.Succeeded) + { + _logger.LogInformation("User created an account using {Name} provider.", info.LoginProvider); + + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user).ConfigureAwait(false); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = userId, code = code }, + protocol: Request.Scheme); + + await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", + $"Please confirm your account by clicking here.").ConfigureAwait(false); + + // If account confirmation is required, we need to show the link if we don't have a real email sender + if (_userManager.Options.SignIn.RequireConfirmedAccount) + { + return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email }); + } + + await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider).ConfigureAwait(false); + return LocalRedirect(returnUrl); + } + } + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + } + + ProviderDisplayName = info.ProviderDisplayName; + ReturnUrl = returnUrl; + return Page(); + } + + private IdentityUser CreateUser() + { + try + { + return Activator.CreateInstance(); + } + catch + { + throw new InvalidOperationException($"Can't create an instance of '{nameof(IdentityUser)}'. " + + $"Ensure that '{nameof(IdentityUser)}' is not an abstract class and has a parameterless constructor, or alternatively " + + $"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml"); + } + } + + private IUserEmailStore GetEmailStore() + { + if (!_userManager.SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)_userStore; + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml new file mode 100644 index 00000000..613aa5b5 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml @@ -0,0 +1,26 @@ +@page +@model ForgotPasswordModel +@{ + ViewData["Title"] = "Forgot your password?"; +} + +

@ViewData["Title"]

+

Enter your email.

+
+
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs new file mode 100644 index 00000000..ba3327a3 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + public class ForgotPasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + + public ForgotPasswordModel(UserManager userManager, IEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + public string Email { get; set; } + } + + public async Task OnPostAsync() + { + if (ModelState.IsValid) + { + var user = await _userManager.FindByEmailAsync(Input.Email).ConfigureAwait(false); + if (user == null || !(await _userManager.IsEmailConfirmedAsync(user).ConfigureAwait(false))) + { + // Don't reveal that the user does not exist or is not confirmed + return RedirectToPage("./ForgotPasswordConfirmation"); + } + + // For more information on how to enable account confirmation and password reset please + // visit https://go.microsoft.com/fwlink/?LinkID=532713 + var code = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ResetPassword", + pageHandler: null, + values: new { area = "Identity", code }, + protocol: Request.Scheme); + + await _emailSender.SendEmailAsync( + Input.Email, + "Reset Password", + $"Please reset your password by clicking here.").ConfigureAwait(false); + + return RedirectToPage("./ForgotPasswordConfirmation"); + } + + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml new file mode 100644 index 00000000..a606993a --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml @@ -0,0 +1,10 @@ +@page +@model ForgotPasswordConfirmation +@{ + ViewData["Title"] = "Forgot password confirmation"; +} + +

@ViewData["Title"]

+

+ Please check your email to reset your password. +

diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs new file mode 100644 index 00000000..639677d0 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPasswordConfirmation.cshtml.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [AllowAnonymous] + public class ForgotPasswordConfirmation : PageModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public void OnGet() + { + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml new file mode 100644 index 00000000..0fa0db06 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml @@ -0,0 +1,10 @@ +@page +@model LockoutModel +@{ + ViewData["Title"] = "Locked out"; +} + +
+

@ViewData["Title"]

+

This account has been locked out, please try again later.

+
diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml.cs new file mode 100644 index 00000000..2e61f641 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Lockout.cshtml.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [AllowAnonymous] + public class LockoutModel : PageModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public void OnGet() + { + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml index 8da303c4..8d74e6ec 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml @@ -10,41 +10,73 @@
-

Use a local account to log in.

+

Use a local account to log in:


-
-
- - +
+
+ +
-
- - +
+ +
-
-
- -
+
+
-
+
+
+

Use another account to log in:

+
+ @{ + if ((Model.ExternalLogins?.Count ?? 0) == 0) + { +
+

+ There are no external authentication services configured. See this article + about setting up this ASP.NET application to support logging in via external services. +

+
+ } + else + { +
+
+

+ @foreach (var provider in Model.ExternalLogins!) + { + + } +

+
+
+ } + } +
+
@section Scripts { diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml.cs index 7a0d6fdb..b3d6bf5e 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml.cs @@ -1,55 +1,82 @@ -using System; +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; -using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; -using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging; namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account { - [AllowAnonymous] public class LoginModel : PageModel { - private readonly UserManager _userManager; private readonly SignInManager _signInManager; private readonly ILogger _logger; - public LoginModel(SignInManager signInManager, - ILogger logger, - UserManager userManager) + public LoginModel(SignInManager signInManager, ILogger logger) { - _userManager = userManager; _signInManager = signInManager; _logger = logger; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [BindProperty] public InputModel Input { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// public IList ExternalLogins { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// public string ReturnUrl { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [TempData] public string ErrorMessage { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// public class InputModel { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [Required] [EmailAddress] public string Email { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [Required] [DataType(DataType.Password)] public string Password { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [Display(Name = "Remember me?")] public bool RememberMe { get; set; } } @@ -61,25 +88,27 @@ public async Task OnGetAsync(string returnUrl = null) ModelState.AddModelError(string.Empty, ErrorMessage); } - returnUrl = returnUrl ?? Url.Content("~/"); + returnUrl ??= Url.Content("~/"); // Clear the existing external cookie to ensure a clean login process - await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme); + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme).ConfigureAwait(false); - ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).ToList(); ReturnUrl = returnUrl; } public async Task OnPostAsync(string returnUrl = null) { - returnUrl = returnUrl ?? Url.Content("~/"); + returnUrl ??= Url.Content("~/"); + + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).ToList(); if (ModelState.IsValid) { // This doesn't count login failures towards account lockout // To enable password failures to trigger account lockout, set lockoutOnFailure: true - var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false); + var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false).ConfigureAwait(false); if (result.Succeeded) { _logger.LogInformation("User logged in."); diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml new file mode 100644 index 00000000..ad0161c7 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml @@ -0,0 +1,21 @@ +@page +@model LogoutModel +@{ + ViewData["Title"] = "Log out"; +} + +
+

@ViewData["Title"]

+ @{ + if (User.Identity?.IsAuthenticated ?? false) + { +
+ +
+ } + else + { +

You have successfully logged out of the application.

+ } + } +
diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml.cs new file mode 100644 index 00000000..65ec26cc --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Logout.cshtml.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + public class LogoutModel : PageModel + { + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public LogoutModel(SignInManager signInManager, ILogger logger) + { + _signInManager = signInManager; + _logger = logger; + } + + public async Task OnPost(string returnUrl = null) + { + await _signInManager.SignOutAsync().ConfigureAwait(false); + _logger.LogInformation("User logged out."); + if (returnUrl != null) + { + return LocalRedirect(returnUrl); + } + else + { + // This needs to be a redirect so that the browser performs a new + // request and the identity for the user gets updated. + return RedirectToPage(); + } + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml new file mode 100644 index 00000000..0b2451f1 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml @@ -0,0 +1,36 @@ +@page +@model ChangePasswordModel +@{ + ViewData["Title"] = "Change password"; + ViewData["ActivePage"] = ManageNavPages.ChangePassword; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs new file mode 100644 index 00000000..ebd9b791 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs @@ -0,0 +1,126 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class ChangePasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public ChangePasswordModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [DataType(DataType.Password)] + [Display(Name = "Current password")] + public string OldPassword { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user).ConfigureAwait(false); + if (!hasPassword) + { + return RedirectToPage("./SetPassword"); + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword).ConfigureAwait(false); + if (!changePasswordResult.Succeeded) + { + foreach (var error in changePasswordResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + await _signInManager.RefreshSignInAsync(user).ConfigureAwait(false); + _logger.LogInformation("User changed their password successfully."); + StatusMessage = "Your password has been changed."; + + return RedirectToPage(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml new file mode 100644 index 00000000..219e583c --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml @@ -0,0 +1,33 @@ +@page +@model DeletePersonalDataModel +@{ + ViewData["Title"] = "Delete Personal Data"; + ViewData["ActivePage"] = ManageNavPages.PersonalData; +} + +

@ViewData["Title"]

+ + + +
+
+
+ @if (Model.RequirePassword) + { +
+ + + +
+ } + +
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs new file mode 100644 index 00000000..a31d745c --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class DeletePersonalDataModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly ILogger _logger; + + public DeletePersonalDataModel( + UserManager userManager, + SignInManager signInManager, + ILogger logger) + { + _userManager = userManager; + _signInManager = signInManager; + _logger = logger; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [DataType(DataType.Password)] + public string Password { get; set; } + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public bool RequirePassword { get; set; } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + RequirePassword = await _userManager.HasPasswordAsync(user).ConfigureAwait(false); + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + RequirePassword = await _userManager.HasPasswordAsync(user).ConfigureAwait(false); + if (RequirePassword) + { + if (!await _userManager.CheckPasswordAsync(user, Input.Password).ConfigureAwait(false)) + { + ModelState.AddModelError(string.Empty, "Incorrect password."); + return Page(); + } + } + + var result = await _userManager.DeleteAsync(user).ConfigureAwait(false); + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + if (!result.Succeeded) + { + throw new InvalidOperationException($"Unexpected error occurred deleting user."); + } + + await _signInManager.SignOutAsync().ConfigureAwait(false); + + _logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId); + + return Redirect("~/"); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml new file mode 100644 index 00000000..eb8b3ba2 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml @@ -0,0 +1,12 @@ +@page +@model DownloadPersonalDataModel +@{ + ViewData["Title"] = "Download Your Data"; + ViewData["ActivePage"] = ManageNavPages.PersonalData; +} + +

@ViewData["Title"]

+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs new file mode 100644 index 00000000..6175ea5d --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/DownloadPersonalData.cshtml.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class DownloadPersonalDataModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public DownloadPersonalDataModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public IActionResult OnGet() + { + return NotFound(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + _logger.LogInformation("User with ID '{UserId}' asked for their personal data.", _userManager.GetUserId(User)); + + // Only include personal data for download + var personalData = new Dictionary(); + var personalDataProps = typeof(IdentityUser).GetProperties().Where( + prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute))); + foreach (var p in personalDataProps) + { + personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null"); + } + + var logins = await _userManager.GetLoginsAsync(user).ConfigureAwait(false); + foreach (var l in logins) + { + personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey); + } + + personalData.Add($"Authenticator Key", await _userManager.GetAuthenticatorKeyAsync(user).ConfigureAwait(false)); + + Response.Headers.Add("Content-Disposition", "attachment; filename=PersonalData.json"); + return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json"); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml new file mode 100644 index 00000000..9e8418b7 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml @@ -0,0 +1,44 @@ +@page +@model EmailModel +@{ + ViewData["Title"] = "Manage Email"; + ViewData["ActivePage"] = ManageNavPages.Email; +} + +

@ViewData["Title"]

+ +
+
+
+
+ @if (Model.IsEmailConfirmed) + { +
+ +
+ +
+ +
+ } + else + { +
+ + + +
+ } +
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs new file mode 100644 index 00000000..48f100a7 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class EmailModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IEmailSender _emailSender; + + public EmailModel( + UserManager userManager, + SignInManager signInManager, + IEmailSender emailSender) + { + _userManager = userManager; + _signInManager = signInManager; + _emailSender = emailSender; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string Email { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public bool IsEmailConfirmed { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + [Display(Name = "New email")] + public string NewEmail { get; set; } + } + + private async Task LoadAsync(IdentityUser user) + { + var email = await _userManager.GetEmailAsync(user).ConfigureAwait(false); + Email = email; + + Input = new InputModel + { + NewEmail = email, + }; + + IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user).ConfigureAwait(false); + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadAsync(user).ConfigureAwait(false); + return Page(); + } + + public async Task OnPostChangeEmailAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user); + return Page(); + } + + var email = await _userManager.GetEmailAsync(user).ConfigureAwait(false); + if (Input.NewEmail != email) + { + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var code = await _userManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail).ConfigureAwait(false); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmailChange", + pageHandler: null, + values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code }, + protocol: Request.Scheme); + await _emailSender.SendEmailAsync( + Input.NewEmail, + "Confirm your email", + $"Please confirm your account by clicking here.").ConfigureAwait(false); + + StatusMessage = "Confirmation link to change email sent. Please check your email."; + return RedirectToPage(); + } + + StatusMessage = "Your email is unchanged."; + return RedirectToPage(); + } + + public async Task OnPostSendVerificationEmailAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user).ConfigureAwait(false); + return Page(); + } + + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var email = await _userManager.GetEmailAsync(user).ConfigureAwait(false); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user).ConfigureAwait(false); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = userId, code = code }, + protocol: Request.Scheme); + await _emailSender.SendEmailAsync( + email, + "Confirm your email", + $"Please confirm your account by clicking here.").ConfigureAwait(false); + + StatusMessage = "Verification email sent. Please check your email."; + return RedirectToPage(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml new file mode 100644 index 00000000..f6171f81 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml @@ -0,0 +1,53 @@ +@page +@model ExternalLoginsModel +@{ + ViewData["Title"] = "Manage your external logins"; + ViewData["ActivePage"] = ManageNavPages.ExternalLogins; +} + + +@if (Model.CurrentLogins?.Count > 0) +{ +

Registered Logins

+ + + @foreach (var login in Model.CurrentLogins) + { + + + + + } + +
@login.ProviderDisplayName + @if (Model.ShowRemoveButton) + { +
+
+ + + +
+
+ } + else + { + @:   + } +
+} +@if (Model.OtherLogins?.Count > 0) +{ +

Add another service to log in.

+
+ +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs new file mode 100644 index 00000000..96c2d289 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class ExternalLoginsModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IUserStore _userStore; + + public ExternalLoginsModel( + UserManager userManager, + SignInManager signInManager, + IUserStore userStore) + { + _userManager = userManager; + _signInManager = signInManager; + _userStore = userStore; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public IList CurrentLogins { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public IList OtherLogins { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public bool ShowRemoveButton { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + CurrentLogins = await _userManager.GetLoginsAsync(user).ConfigureAwait(false); + OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)) + .Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider)) + .ToList(); + + string passwordHash = null; + if (_userStore is IUserPasswordStore userPasswordStore) + { + passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted).ConfigureAwait(false); + } + + ShowRemoveButton = passwordHash != null || CurrentLogins.Count > 1; + return Page(); + } + + public async Task OnPostRemoveLoginAsync(string loginProvider, string providerKey) + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var result = await _userManager.RemoveLoginAsync(user, loginProvider, providerKey).ConfigureAwait(false); + if (!result.Succeeded) + { + StatusMessage = "The external login was not removed."; + return RedirectToPage(); + } + + await _signInManager.RefreshSignInAsync(user).ConfigureAwait(false); + StatusMessage = "The external login was removed."; + return RedirectToPage(); + } + + public async Task OnPostLinkLoginAsync(string provider) + { + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme).ConfigureAwait(false); + + // Request a redirect to the external login provider to link a login for the current user + var redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback"); + var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User)); + return new ChallengeResult(provider, properties); + } + + public async Task OnGetLinkLoginCallbackAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var info = await _signInManager.GetExternalLoginInfoAsync(userId).ConfigureAwait(false); + if (info == null) + { + throw new InvalidOperationException($"Unexpected error occurred loading external login info."); + } + + var result = await _userManager.AddLoginAsync(user, info).ConfigureAwait(false); + if (!result.Succeeded) + { + StatusMessage = "The external login was not added. External logins can only be associated with one account."; + return RedirectToPage(); + } + + // Clear the existing external cookie to ensure a clean login process + await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme).ConfigureAwait(false); + + StatusMessage = "The external login was added."; + return RedirectToPage(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml new file mode 100644 index 00000000..9ec95b33 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml @@ -0,0 +1,30 @@ +@page +@model IndexModel +@{ + ViewData["Title"] = "Profile"; + ViewData["ActivePage"] = ManageNavPages.Index; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs new file mode 100644 index 00000000..074603f3 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs @@ -0,0 +1,116 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class IndexModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public IndexModel( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string Username { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Phone] + [Display(Name = "Phone number")] + public string PhoneNumber { get; set; } + } + + private async Task LoadAsync(IdentityUser user) + { + var userName = await _userManager.GetUserNameAsync(user).ConfigureAwait(false); + var phoneNumber = await _userManager.GetPhoneNumberAsync(user).ConfigureAwait(false); + + Username = userName; + + Input = new InputModel + { + PhoneNumber = phoneNumber + }; + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + await LoadAsync(user).ConfigureAwait(false); + return Page(); + } + + public async Task OnPostAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + if (!ModelState.IsValid) + { + await LoadAsync(user).ConfigureAwait(false); + return Page(); + } + + var phoneNumber = await _userManager.GetPhoneNumberAsync(user).ConfigureAwait(false); + if (Input.PhoneNumber != phoneNumber) + { + var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber).ConfigureAwait(false); + if (!setPhoneResult.Succeeded) + { + StatusMessage = "Unexpected error when trying to set phone number."; + return RedirectToPage(); + } + } + + await _signInManager.RefreshSignInAsync(user).ConfigureAwait(false); + StatusMessage = "Your profile has been updated"; + return RedirectToPage(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs new file mode 100644 index 00000000..b536b1f3 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -0,0 +1,123 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static class ManageNavPages + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string Index => "Index"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string Email => "Email"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string ChangePassword => "ChangePassword"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string DownloadPersonalData => "DownloadPersonalData"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string DeletePersonalData => "DeletePersonalData"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string ExternalLogins => "ExternalLogins"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string PersonalData => "PersonalData"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string TwoFactorAuthentication => "TwoFactorAuthentication"; + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public static string PageNavClass(ViewContext viewContext, string page) + { + var activePage = viewContext.ViewData["ActivePage"] as string + ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); + return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null; + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml new file mode 100644 index 00000000..a29c5141 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml @@ -0,0 +1,27 @@ +@page +@model PersonalDataModel +@{ + ViewData["Title"] = "Personal Data"; + ViewData["ActivePage"] = ManageNavPages.PersonalData; +} + +

@ViewData["Title"]

+ +
+
+

Your account contains personal data that you have given us. This page allows you to download or delete that data.

+

+ Deleting this data will permanently remove your account, and this cannot be recovered. +

+
+ +
+

+ Delete +

+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs new file mode 100644 index 00000000..b63a911e --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs @@ -0,0 +1,36 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class PersonalDataModel : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + + public PersonalDataModel( + UserManager userManager, + ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml new file mode 100644 index 00000000..6ca07495 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml @@ -0,0 +1,35 @@ +@page +@model SetPasswordModel +@{ + ViewData["Title"] = "Set password"; + ViewData["ActivePage"] = ManageNavPages.ChangePassword; +} + +

Set your password

+ +

+ You do not have a local username/password for this site. Add a local + account so you can log in without an external login. +

+
+
+
+
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs new file mode 100644 index 00000000..00b9b430 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage +{ + public class SetPasswordModel : PageModel + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + + public SetPasswordModel( + UserManager userManager, + SignInManager signInManager) + { + _userManager = userManager; + _signInManager = signInManager; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [TempData] + public string StatusMessage { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + [Display(Name = "New password")] + public string NewPassword { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [DataType(DataType.Password)] + [Display(Name = "Confirm new password")] + [Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + } + + public async Task OnGetAsync() + { + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var hasPassword = await _userManager.HasPasswordAsync(user).ConfigureAwait(false); + + if (hasPassword) + { + return RedirectToPage("./ChangePassword"); + } + + return Page(); + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.GetUserAsync(User).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword).ConfigureAwait(false); + if (!addPasswordResult.Succeeded) + { + foreach (var error in addPasswordResult.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + + await _signInManager.RefreshSignInAsync(user).ConfigureAwait(false); + StatusMessage = "Your password has been set."; + + return RedirectToPage(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_Layout.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_Layout.cshtml new file mode 100644 index 00000000..d97cd4e2 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_Layout.cshtml @@ -0,0 +1,29 @@ +@{ + if (ViewData.TryGetValue("ParentLayout", out var parentLayout) && parentLayout != null) + { + Layout = parentLayout.ToString(); + } + else + { + Layout = "/Areas/Identity/Pages/_Layout.cshtml"; + } +} + +

Manage your account

+ +
+

Change your account settings

+
+
+
+ +
+
+ @RenderBody() +
+
+
+ +@section Scripts { + @RenderSection("Scripts", required: false) +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml new file mode 100644 index 00000000..98681181 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -0,0 +1,14 @@ +@inject SignInManager SignInManager +@{ + var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).Any(); +} + diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml new file mode 100644 index 00000000..5051306d --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml @@ -0,0 +1,10 @@ +@model string + +@if (!String.IsNullOrEmpty(Model)) +{ + var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml new file mode 100644 index 00000000..6104b65b --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml @@ -0,0 +1 @@ +@using Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account.Manage \ No newline at end of file diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml index 68a104b6..99b730f4 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml @@ -8,28 +8,58 @@
-
-

Create a new account.

+ +

Create a new account.


-
-
+
+
+ -
-
+
+ -
-
+
+ -
- +
+
+
+

Use another service to register.

+
+ @{ + if ((Model.ExternalLogins?.Count ?? 0) == 0) + { +
+

+ There are no external authentication services configured. See this article + about setting up this ASP.NET application to support logging in via external services. +

+
+ } + else + { +
+
+

+ @foreach (var provider in Model.ExternalLogins!) + { + + } +

+
+
+ } + } +
+
@section Scripts { diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs index 131f65b1..b6635e5b 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Register.cshtml.cs @@ -1,12 +1,16 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Text; using System.Text.Encodings.Web; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Mvc; @@ -16,80 +20,118 @@ namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account { - [AllowAnonymous] public class RegisterModel : PageModel { private readonly SignInManager _signInManager; private readonly UserManager _userManager; + private readonly IUserStore _userStore; + private readonly IUserEmailStore _emailStore; private readonly ILogger _logger; private readonly IEmailSender _emailSender; public RegisterModel( UserManager userManager, + IUserStore userStore, SignInManager signInManager, ILogger logger, IEmailSender emailSender) { _userManager = userManager; + _userStore = userStore; + _emailStore = GetEmailStore(); _signInManager = signInManager; _logger = logger; _emailSender = emailSender; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [BindProperty] public InputModel Input { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// public string ReturnUrl { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// public IList ExternalLogins { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// public class InputModel { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [Required] [EmailAddress] [Display(Name = "Email")] public string Email { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// [DataType(DataType.Password)] [Display(Name = "Confirm password")] [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] public string ConfirmPassword { get; set; } } + public async Task OnGetAsync(string returnUrl = null) { ReturnUrl = returnUrl; - ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).ToList(); } public async Task OnPostAsync(string returnUrl = null) { - returnUrl = returnUrl ?? Url.Content("~/"); - ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList(); + returnUrl ??= Url.Content("~/"); + ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync().ConfigureAwait(false)).ToList(); if (ModelState.IsValid) { - var user = new IdentityUser { UserName = Input.Email, Email = Input.Email }; - var result = await _userManager.CreateAsync(user, Input.Password); + var user = CreateUser(); + + await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None).ConfigureAwait(false); + await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None).ConfigureAwait(false); + var result = await _userManager.CreateAsync(user, Input.Password).ConfigureAwait(false); + if (result.Succeeded) { _logger.LogInformation("User created a new account with password."); - var code = await _userManager.GenerateEmailConfirmationTokenAsync(user); + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user).ConfigureAwait(false); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var callbackUrl = Url.Page( "/Account/ConfirmEmail", pageHandler: null, - values: new { area = "Identity", userId = user.Id, code = code, returnUrl = returnUrl }, + values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, protocol: Request.Scheme); await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", - $"Please confirm your account by clicking here."); + $"Please confirm your account by clicking here.").ConfigureAwait(false); if (_userManager.Options.SignIn.RequireConfirmedAccount) { @@ -97,7 +139,7 @@ await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", } else { - await _signInManager.SignInAsync(user, isPersistent: false); + await _signInManager.SignInAsync(user, isPersistent: false).ConfigureAwait(false); return LocalRedirect(returnUrl); } } @@ -110,5 +152,28 @@ await _emailSender.SendEmailAsync(Input.Email, "Confirm your email", // If we got this far, something failed, redisplay form return Page(); } + + private IdentityUser CreateUser() + { + try + { + return Activator.CreateInstance(); + } + catch + { + throw new InvalidOperationException($"Can't create an instance of '{nameof(IdentityUser)}'. " + + $"Ensure that '{nameof(IdentityUser)}' is not an abstract class and has a parameterless constructor, or alternatively " + + $"override the register page in /Areas/Identity/Pages/Account/Register.cshtml"); + } + } + + private IUserEmailStore GetEmailStore() + { + if (!_userManager.SupportsUserEmail) + { + throw new NotSupportedException("The default UI requires a user store with email support."); + } + return (IUserEmailStore)_userStore; + } } } diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml new file mode 100644 index 00000000..c6267756 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml @@ -0,0 +1,22 @@ +@page +@model RegisterConfirmationModel +@{ + ViewData["Title"] = "Register confirmation"; +} + +

@ViewData["Title"]

+@{ + if (@Model.DisplayConfirmAccountLink) + { +

+ Click here to confirm your account +

+ } + else + { +

+ Please check your email to confirm your account. +

+ } +} + diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs new file mode 100644 index 00000000..63dc1bf2 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs @@ -0,0 +1,78 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class RegisterConfirmationModel : PageModel + { + private readonly UserManager _userManager; + private readonly IEmailSender _sender; + + public RegisterConfirmationModel(UserManager userManager, IEmailSender sender) + { + _userManager = userManager; + _sender = sender; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string Email { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public bool DisplayConfirmAccountLink { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public string EmailConfirmationUrl { get; set; } + + public async Task OnGetAsync(string email, string returnUrl = null) + { + if (email == null) + { + return RedirectToPage("/Index"); + } + returnUrl = returnUrl ?? Url.Content("~/"); + + var user = await _userManager.FindByEmailAsync(email).ConfigureAwait(false); + if (user == null) + { + return NotFound($"Unable to load user with email '{email}'."); + } + + Email = email; + // Once you add a real email sender, you should remove this code that lets you confirm the account + DisplayConfirmAccountLink = false; + if (DisplayConfirmAccountLink) + { + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user).ConfigureAwait(false); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + EmailConfirmationUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl }, + protocol: Request.Scheme); + } + + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml new file mode 100644 index 00000000..cd983d7e --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml @@ -0,0 +1,26 @@ +@page +@model ResendEmailConfirmationModel +@{ + ViewData["Title"] = "Resend email confirmation"; +} + +

@ViewData["Title"]

+

Enter your email.

+
+
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs new file mode 100644 index 00000000..3f17f501 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + [AllowAnonymous] + public class ResendEmailConfirmationModel : PageModel + { + private readonly UserManager _userManager; + private readonly IEmailSender _emailSender; + + public ResendEmailConfirmationModel(UserManager userManager, IEmailSender emailSender) + { + _userManager = userManager; + _emailSender = emailSender; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + public string Email { get; set; } + } + + public void OnGet() + { + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.FindByEmailAsync(Input.Email).ConfigureAwait(false); + if (user == null) + { + ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); + return Page(); + } + + var userId = await _userManager.GetUserIdAsync(user).ConfigureAwait(false); + var code = await _userManager.GenerateEmailConfirmationTokenAsync(user).ConfigureAwait(false); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + var callbackUrl = Url.Page( + "/Account/ConfirmEmail", + pageHandler: null, + values: new { userId = userId, code = code }, + protocol: Request.Scheme); + await _emailSender.SendEmailAsync( + Input.Email, + "Confirm your email", + $"Please confirm your account by clicking here.").ConfigureAwait(false); + + ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email."); + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml new file mode 100644 index 00000000..2d87e3f7 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml @@ -0,0 +1,37 @@ +@page +@model ResetPasswordModel +@{ + ViewData["Title"] = "Reset password"; +} + +

@ViewData["Title"]

+

Reset your password.

+
+
+
+
+
+ +
+ + + +
+
+ + + +
+
+ + + +
+ +
+
+
+ +@section Scripts { + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs new file mode 100644 index 00000000..8314c320 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -0,0 +1,115 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using System.ComponentModel.DataAnnotations; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.WebUtilities; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + public class ResetPasswordModel : PageModel + { + private readonly UserManager _userManager; + + public ResetPasswordModel(UserManager userManager) + { + _userManager = userManager; + } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [BindProperty] + public InputModel Input { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public class InputModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [EmailAddress] + public string Email { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] + [DataType(DataType.Password)] + public string Password { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [DataType(DataType.Password)] + [Display(Name = "Confirm password")] + [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] + public string ConfirmPassword { get; set; } + + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [Required] + public string Code { get; set; } + + } + + public IActionResult OnGet(string code = null) + { + if (code == null) + { + return BadRequest("A code must be supplied for password reset."); + } + else + { + Input = new InputModel + { + Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)) + }; + return Page(); + } + } + + public async Task OnPostAsync() + { + if (!ModelState.IsValid) + { + return Page(); + } + + var user = await _userManager.FindByEmailAsync(Input.Email).ConfigureAwait(false); + if (user == null) + { + // Don't reveal that the user does not exist + return RedirectToPage("./ResetPasswordConfirmation"); + } + + var result = await _userManager.ResetPasswordAsync(user, Input.Code, Input.Password).ConfigureAwait(false); + if (result.Succeeded) + { + return RedirectToPage("./ResetPasswordConfirmation"); + } + + foreach (var error in result.Errors) + { + ModelState.AddModelError(string.Empty, error.Description); + } + return Page(); + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml new file mode 100644 index 00000000..394c8db2 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml @@ -0,0 +1,10 @@ +@page +@model ResetPasswordConfirmationModel +@{ + ViewData["Title"] = "Reset password confirmation"; +} + +

@ViewData["Title"]

+

+ Your password has been reset. Please click here to log in. +

diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs new file mode 100644 index 00000000..6fa32507 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +#nullable disable + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Opc.Ua.Cloud.Library.Areas.Identity.Pages.Account +{ + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + [AllowAnonymous] + public class ResetPasswordConfirmationModel : PageModel + { + /// + /// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used + /// directly from your code. This API may change or be removed in future releases. + /// + public void OnGet() + { + } + } +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/_StatusMessage.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/_StatusMessage.cshtml new file mode 100644 index 00000000..5051306d --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/_StatusMessage.cshtml @@ -0,0 +1,10 @@ +@model string + +@if (!String.IsNullOrEmpty(Model)) +{ + var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success"; + +} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml index 6d138d27..20aaa1f6 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/_ValidationScriptsPartial.cshtml @@ -3,16 +3,16 @@ - - diff --git a/UACloudLibraryServer/BasicAuthenticationHandler.cs b/UACloudLibraryServer/BasicAuthenticationHandler.cs index 18e0ba53..d25d087e 100644 --- a/UACloudLibraryServer/BasicAuthenticationHandler.cs +++ b/UACloudLibraryServer/BasicAuthenticationHandler.cs @@ -79,6 +79,7 @@ protected override async Task HandleAuthenticateAsync() return AuthenticateResult.Success(ticket2); } + throw new ArgumentException("Authentication header missing in request!"); } @@ -97,10 +98,12 @@ protected override async Task HandleAuthenticateAsync() { return AuthenticateResult.Fail($"Authentication failed: {ex.Message}"); } + if (claims == null) { throw new ArgumentException("Invalid credentials"); } + ClaimsIdentity identity = new ClaimsIdentity(claims, Scheme.Name); ClaimsPrincipal principal = new ClaimsPrincipal(identity); AuthenticationTicket ticket = new AuthenticationTicket(principal, Scheme.Name); diff --git a/UACloudLibraryServer/CloudLibDataProvider.cs b/UACloudLibraryServer/CloudLibDataProvider.cs index 33da841a..512317bf 100644 --- a/UACloudLibraryServer/CloudLibDataProvider.cs +++ b/UACloudLibraryServer/CloudLibDataProvider.cs @@ -233,7 +233,7 @@ await _dbContext.Categories.FirstOrDefaultAsync(c => c.Name == uaNamespace.Categ // This will only run on failures during transaction commit, where the EF can not determine if the Tx was committed or not () => _dbContext.nodeSetsWithUnapproved.AsNoTracking() .AnyAsync(n => n.ModelUri == nodeSet.Models[0].ModelUri && n.PublicationDate == (nodeSet.Models[0].PublicationDateSpecified ? nodeSet.Models[0].PublicationDate : default)) - ); + ).ConfigureAwait(false); } catch (Exception ex) { @@ -249,7 +249,7 @@ public async Task IncrementDownloadCountAsync(uint nodesetId) var namespaceMeta = await _dbContext.NamespaceMetaDataWithUnapproved.FirstOrDefaultAsync(n => n.NodesetId == nodesetId.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); namespaceMeta.NumberOfDownloads++; var newCount = namespaceMeta.NumberOfDownloads; - await _dbContext.SaveChangesAsync(); + await _dbContext.SaveChangesAsync().ConfigureAwait(false); return newCount; } @@ -451,7 +451,7 @@ public int GetNamespaceTotalCount() public async Task ApproveNamespaceAsync(string identifier, ApprovalStatus status, string approvalInformation, List additionalProperties) { - var nodeSetMeta = await _dbContext.NamespaceMetaDataWithUnapproved.Where(n => n.NodesetId == identifier).FirstOrDefaultAsync(); + var nodeSetMeta = await _dbContext.NamespaceMetaDataWithUnapproved.Where(n => n.NodesetId == identifier).FirstOrDefaultAsync().ConfigureAwait(false); if (nodeSetMeta == null) return null; nodeSetMeta.ApprovalStatus = status; @@ -468,7 +468,7 @@ public async Task ApproveNamespaceAsync(string identifie _logger.LogWarning($"Failed to delete file on Approval cancelation for {nodeSetMeta.NodesetId}: {ex.Message}"); } - if (!await DeleteAllRecordsForNodesetAsync(uint.Parse(nodeSetMeta.NodesetId, CultureInfo.InvariantCulture))) + if (!await DeleteAllRecordsForNodesetAsync(uint.Parse(nodeSetMeta.NodesetId, CultureInfo.InvariantCulture)).ConfigureAwait(false)) { _logger.LogWarning($"Failed to delete records on Approval cancelation for {nodeSetMeta.NodesetId}"); return null; @@ -502,8 +502,8 @@ public async Task ApproveNamespaceAsync(string identifie } } } - await _dbContext.SaveChangesAsync(); - var nodeSetMetaSaved = await _dbContext.NamespaceMetaDataWithUnapproved.Where(n => n.NodesetId == identifier).FirstOrDefaultAsync(); + await _dbContext.SaveChangesAsync().ConfigureAwait(false); + var nodeSetMetaSaved = await _dbContext.NamespaceMetaDataWithUnapproved.Where(n => n.NodesetId == identifier).FirstOrDefaultAsync().ConfigureAwait(false); return nodeSetMetaSaved; } diff --git a/UACloudLibraryServer/Controllers/ApprovalController.cs b/UACloudLibraryServer/Controllers/ApprovalController.cs index e79914fc..07fe3a5d 100644 --- a/UACloudLibraryServer/Controllers/ApprovalController.cs +++ b/UACloudLibraryServer/Controllers/ApprovalController.cs @@ -63,7 +63,7 @@ public async Task ApproveNameSpaceAsync( [FromQuery][SwaggerParameter("Status of the approval")] ApprovalStatus status, [FromQuery][SwaggerParameter("Information about the approval")] string approvalInformation) { - if (await _database.ApproveNamespaceAsync(identifier, status, approvalInformation, null) != null) + if (await _database.ApproveNamespaceAsync(identifier, status, approvalInformation, null).ConfigureAwait(false) != null) { return new ObjectResult("Approval status updated successfully") { StatusCode = (int)HttpStatusCode.OK }; } @@ -82,7 +82,7 @@ public async Task AddRoleAsync( [FromServices] RoleManager roleManager ) { - var result = await roleManager.CreateAsync(new IdentityRole { Name = roleName }); + var result = await roleManager.CreateAsync(new IdentityRole { Name = roleName }).ConfigureAwait(false); if (!result.Succeeded) { return this.BadRequest(result); @@ -101,12 +101,12 @@ public async Task AddRoleToUserAsync( [FromServices] UserManager userManager ) { - var user = await userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId).ConfigureAwait(false); if (user == null) { return NotFound(); } - var result = await userManager.AddToRoleAsync(user, roleName); + var result = await userManager.AddToRoleAsync(user, roleName).ConfigureAwait(false); if (!result.Succeeded) { return this.BadRequest(result); diff --git a/UACloudLibraryServer/Controllers/ExplorerController.cs b/UACloudLibraryServer/Controllers/ExplorerController.cs index ea937942..573e6d1b 100644 --- a/UACloudLibraryServer/Controllers/ExplorerController.cs +++ b/UACloudLibraryServer/Controllers/ExplorerController.cs @@ -1,13 +1,38 @@ -using System; -using System.Net.Http; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Authentication; +/* ======================================================================== + * Copyright (c) 2005-2021 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Opc.Ua.Cloud.Library.Controllers { - [Authorize] + [Authorize(AuthenticationSchemes = "BasicAuthentication")] public class ExplorerController : Controller { // GET diff --git a/UACloudLibraryServer/Controllers/InfoModelController.cs b/UACloudLibraryServer/Controllers/InfoModelController.cs index afd6a9b6..4eb84e27 100644 --- a/UACloudLibraryServer/Controllers/InfoModelController.cs +++ b/UACloudLibraryServer/Controllers/InfoModelController.cs @@ -40,10 +40,7 @@ namespace Opc.Ua.Cloud.Library using Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; - using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; - using Npgsql; - using Opc.Ua; using Opc.Ua.Cloud.Library.Interfaces; using Opc.Ua.Cloud.Library.Models; using Opc.Ua.Export; @@ -127,7 +124,7 @@ public async Task DownloadNamespaceAsync( if (nodesetXMLOnly) { - await _database.IncrementDownloadCountAsync(nodeSetID); + await _database.IncrementDownloadCountAsync(nodeSetID).ConfigureAwait(false); return new ObjectResult(nodesetXml) { StatusCode = (int)HttpStatusCode.OK }; } @@ -141,7 +138,7 @@ public async Task DownloadNamespaceAsync( if (!metadataOnly) { // Only count downloads with XML payload - await _database.IncrementDownloadCountAsync(nodeSetID); + await _database.IncrementDownloadCountAsync(nodeSetID).ConfigureAwait(false); } return new ObjectResult(uaNamespace) { StatusCode = (int)HttpStatusCode.OK }; } diff --git a/UACloudLibraryServer/GraphQL/ApprovalModel.cs b/UACloudLibraryServer/GraphQL/ApprovalModel.cs index 2af6a8db..d240d825 100644 --- a/UACloudLibraryServer/GraphQL/ApprovalModel.cs +++ b/UACloudLibraryServer/GraphQL/ApprovalModel.cs @@ -61,7 +61,7 @@ public class ApprovalInput [Authorize(Policy = "ApprovalPolicy")] public async Task ApproveNodeSetAsync([Service(ServiceKind.Synchronized)] IDatabase db, ApprovalInput input) { - var nodeSet = await db.ApproveNamespaceAsync(input.Identifier, input.Status, input.ApprovalInformation, input.AdditionalProperties); + var nodeSet = await db.ApproveNamespaceAsync(input.Identifier, input.Status, input.ApprovalInformation, input.AdditionalProperties).ConfigureAwait(false); return nodeSet; } diff --git a/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs b/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs index 5ad7aa26..cb0a4f71 100644 --- a/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs +++ b/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs @@ -152,7 +152,7 @@ public Task> ImportNodeSetModelAsync(string nodeSetXML, strin public async Task CreateNodeSetModelFromNodeSetAsync(UANodeSet nodeSet, string identifier, string userId) { var nodeSetModel = await CreateNodeSetModelFromNodeSetAsync(_dbContext, nodeSet, identifier, userId).ConfigureAwait(false); - await _dbContext.AddAsync(nodeSetModel); + await _dbContext.AddAsync(nodeSetModel).ConfigureAwait(false); await _dbContext.SaveChangesAsync().ConfigureAwait(false); return nodeSetModel; } @@ -266,7 +266,7 @@ private static async Task IndexNodeSetsInternalAsync(NodeSetModelIndexerFac var nodeSetIndexer = factory.Create(); try { - await nodeSetIndexer.IndexMissingNodeSets(); + await nodeSetIndexer.IndexMissingNodeSets().ConfigureAwait(false); var unvalidatedNodeSets = await nodeSetIndexer._dbContext.nodeSetsWithUnapproved.Where(n => n.ValidationStatus != ValidationStatus.Indexed) .ToListAsync().ConfigureAwait(false); @@ -377,7 +377,7 @@ private async Task IndexMissingNodeSets() _logger.LogDebug($"Parsing missing nodeset {missingNodeSetId}"); var uaNodeSet = InfoModelController.ReadUANodeSet(nodeSetXml); - var existingNodeSet = await _dbContext.nodeSets.FirstOrDefaultAsync(n => n.ModelUri == uaNodeSet.Models[0].ModelUri && n.PublicationDate == uaNodeSet.Models[0].PublicationDate); + var existingNodeSet = await _dbContext.nodeSets.FirstOrDefaultAsync(n => n.ModelUri == uaNodeSet.Models[0].ModelUri && n.PublicationDate == uaNodeSet.Models[0].PublicationDate).ConfigureAwait(false); if (existingNodeSet != null) { _logger.LogWarning($"Metadata vs. NodeSets inconsistency for {existingNodeSet}: Metadata NodeSetId {missingNodeSetId} vs. NodeSet {existingNodeSet.Identifier}"); diff --git a/UACloudLibraryServer/Startup.cs b/UACloudLibraryServer/Startup.cs index ad464008..312ca1cd 100644 --- a/UACloudLibraryServer/Startup.cs +++ b/UACloudLibraryServer/Startup.cs @@ -30,16 +30,23 @@ namespace Opc.Ua.Cloud.Library { using System; + using System.Collections.Generic; + using System.Globalization; using System.IO; + using System.Net.Http; + using System.Security.Claims; + using System.Text.Json; using Amazon.S3; using GraphQL.Server.Ui.Playground; using HotChocolate.AspNetCore; using HotChocolate.Data; using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.OAuth; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.UI.Services; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -96,7 +103,47 @@ public void ConfigureServices(IServiceCollection services) services.AddLogging(builder => builder.AddConsole()); services.AddAuthentication() - .AddScheme("BasicAuthentication", null); + .AddScheme("BasicAuthentication", null) + .AddOAuth("OAuth", "OPC Foundation", options => + { + options.AuthorizationEndpoint = "https://opcfoundation.org/oauth/authorize/"; + options.TokenEndpoint = "https://opcfoundation.org/oauth/token/"; + options.UserInformationEndpoint = "https://opcfoundation.org/oauth/me"; + + options.AccessDeniedPath = new PathString("/Account/AccessDenied"); + options.CallbackPath = new PathString("/Account/ExternalLogin"); + + options.ClientId = Configuration["OAuth2ClientId"]; + options.ClientSecret = Configuration["OAuth2ClientSecret"]; + + options.SaveTokens = true; + + options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "ID"); + options.ClaimActions.MapJsonKey(ClaimTypes.Name, "display_name"); + options.ClaimActions.MapJsonKey(ClaimTypes.Email, "user_email"); + + options.Events = new OAuthEvents { + OnCreatingTicket = async context => + { + List tokens = (List)context.Properties.GetTokens(); + + tokens.Add(new AuthenticationToken() { + Name = "TicketCreated", + Value = DateTime.UtcNow.ToString(DateTimeFormatInfo.InvariantInfo) + }); + + context.Properties.StoreTokens(tokens); + + HttpResponseMessage response = await context.Backchannel.GetAsync($"{context.Options.UserInformationEndpoint}?access_token={context.AccessToken}").ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + JsonElement user = JsonDocument.Parse(json).RootElement; + + context.RunClaimActions(user); + } + }; + }); services.AddAuthorization(options => { options.AddPolicy("ApprovalPolicy", policy => policy.RequireRole("Administrator")); @@ -115,14 +162,6 @@ public void ConfigureServices(IServiceCollection services) } }); - options.AddSecurityDefinition("basic", new OpenApiSecurityScheme { - Name = "Authorization", - Type = SecuritySchemeType.Http, - Scheme = "basic", - In = ParameterLocation.Header, - Description = "Basic Authorization header using the Bearer scheme." - }); - options.AddSecurityRequirement(new OpenApiSecurityRequirement { { diff --git a/UACloudLibraryServer/UserService.cs b/UACloudLibraryServer/UserService.cs index dca0eed4..ebeb4bb5 100644 --- a/UACloudLibraryServer/UserService.cs +++ b/UACloudLibraryServer/UserService.cs @@ -100,12 +100,12 @@ public async Task> ValidateCredentialsAsync(string username, } List claims = new(); claims.Add(new Claim(ClaimTypes.Name, username)); - var roles = await _userManager.GetRolesAsync(user); + var roles = await _userManager.GetRolesAsync(user).ConfigureAwait(false); foreach (var role in roles) { claims.Add(new Claim(ClaimTypes.Role, role)); } - claims.AddRange(await this._userManager.GetClaimsAsync(user)); + claims.AddRange(await this._userManager.GetClaimsAsync(user).ConfigureAwait(false)); return claims; } } From 65f1a2321911efd779b193ad5b668d1897972b8a Mon Sep 17 00:00:00 2001 From: Erich Barnstedt Date: Wed, 20 Sep 2023 14:48:31 +0200 Subject: [PATCH 4/8] Set cookie policy for hosting on Azure (#190) * Rename addressspace -> namespace * Disable logerror warning. * Fix using Async in method names. * Implement disposeable on postgresql implementation. * bug fix. * Bug and code analyzer warning fixes and fix naming of cloudlibclient variables. * Fix regression. * Fix regression. * Fix formatting. * fix formatting. * fix formatting. * fix formatting. * Fix formatting. * Added docker compose back into solution file and deleted extra solution file. * Fix one more (misspelt) addressspace -> namespace rename. * Remove dups from category and org queries. * Remove unneeded using. * Fix cut and paste error. * Fix client lib. * Property name change from nodesetCreationTime to publicationDate to be consistent. * Fix for bug https://github.com/OPCFoundation/UA-CloudLibrary/issues/90 * Fixes security CVE-2022-30187 and maintain stack info on exception. * Fix build break and CVEs. * Added Azure Data Protection Key per instance and updated NuGets. * Fix version string. * Fix CloudLib Explorer exception and many async and dependency injection-related issues. * update version to match spec. * Fix usings/whitespace. * Move QueryModel to GraphQL folder. * Switch to Postmark as Sendgrid disabled our account and was unreliable anyway. * Bring sendgrid back in optionally. * Added description for data protection env variable. * Fix CVE and update other NuGets, too. * Switch to localhost for DB endpoint and add missing env variable. * Re-enabling builsing the sync tool. * NodeSetModel 1.0.13 plus migrations * File encoding fix * Update to latest OPC UA stack to avoid security issue. * 1. Add OAuth2 auth via OPC Foundation website and additional login UI from ASP.NetCore Identity Scafolding. 2. Add missing ConfigureAwaits. 3. Simplify auth in Swagger. 4. Add account management UI from ASP.NetCore Identity Scafolding. 5. Add email confirmation management from ASP.NetCore Identity Scafolding. * Remove unnecessary usings. * Test: add oauth2 config * Bug fix. --------- Co-authored-by: Markus Horstmann --- UACloudLibraryServer/Startup.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/UACloudLibraryServer/Startup.cs b/UACloudLibraryServer/Startup.cs index 312ca1cd..f45e11a6 100644 --- a/UACloudLibraryServer/Startup.cs +++ b/UACloudLibraryServer/Startup.cs @@ -118,6 +118,9 @@ public void ConfigureServices(IServiceCollection services) options.SaveTokens = true; + options.CorrelationCookie.SameSite = SameSiteMode.Strict; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "ID"); options.ClaimActions.MapJsonKey(ClaimTypes.Name, "display_name"); options.ClaimActions.MapJsonKey(ClaimTypes.Email, "user_email"); From 11ae06115a7abc1adebfc7f004d2dc6ed1be49ea Mon Sep 17 00:00:00 2001 From: MarkusHorstmann Date: Mon, 25 Sep 2023 11:16:30 -0700 Subject: [PATCH 5/8] Azure AD auth, delete NodeSet API (#189) * Microsoft Identity (aka Azure AD) support * Delete API * Test case for nodesets with depedencies * #187 ObjectDisposedException in Explorer.razor fetchData() * GraphQL: make pagination sizes configurable --- Spec/graphql/CESMII stage 2022-11-17.graphql | 2636 +++++++++++++++++ ...ingtestnodeset001.V1_2.NodeSet2.xml.0.json | 54 + ...ts.testnodeset001.V1_2.NodeSet2.xml.0.json | 2 +- .../CloudLibClientTests/QueriesAndDownload.cs | 68 +- ...ibtests.testnodeset001.NodeSet2.xml.0.json | 2 +- .../Pages/Account/ExternalLogin.cshtml.cs | 3 +- .../Areas/Identity/Pages/Account/Login.cshtml | 37 +- .../Pages/Account/Manage/Email.cshtml.cs | 3 +- .../Pages/Account/Manage/Index.cshtml.cs | 3 +- .../Pages/Account/ResetPassword.cshtml.cs | 3 +- .../ResetPasswordConfirmation.cshtml.cs | 2 +- .../CloudLibDataProviderLegacyMetadata.cs | 1 + .../Components/Pages/Explorer.razor | 13 +- .../Controllers/AccessController.cs | 97 + .../Controllers/ApprovalController.cs | 45 +- .../Controllers/InfoModelController.cs | 41 +- UACloudLibraryServer/GraphQL/ApprovalModel.cs | 3 - .../NodeSetIndex/NodeSetModelIndexer.cs | 1 + .../NodeSetIndex/UANodeSetIFileStorage.cs | 1 + UACloudLibraryServer/Startup.cs | 136 +- UACloudLibraryServer/UA-CloudLibrary.csproj | 15 +- 21 files changed, 3012 insertions(+), 154 deletions(-) create mode 100644 Spec/graphql/CESMII stage 2022-11-17.graphql create mode 100644 Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.dependingtestnodeset001.V1_2.NodeSet2.xml.0.json create mode 100644 UACloudLibraryServer/Controllers/AccessController.cs diff --git a/Spec/graphql/CESMII stage 2022-11-17.graphql b/Spec/graphql/CESMII stage 2022-11-17.graphql new file mode 100644 index 00000000..8b48a88b --- /dev/null +++ b/Spec/graphql/CESMII stage 2022-11-17.graphql @@ -0,0 +1,2636 @@ +schema { + query: QueryModel +} + +# The `@defer` directive may be provided for fragment spreads and inline fragments to inform the executor to delay the execution of the current fragment to indicate deprioritization of the current fragment. A query with `@defer` directive will cause the request to potentially return multiple responses, where non-deferred data is delivered in the initial response and data deferred is delivered in a subsequent response. `@include` and `@skip` take precedence over `@defer`. +directive @defer( + # If this argument label has a value other than null, it will be passed on to the result of this defer directive. This label is intended to give client applications a way to identify to which fragment a deferred result belongs to. + label: String + + # Deferred when true. + if: Boolean +) on FRAGMENT_SPREAD | INLINE_FRAGMENT + +# The `@stream` directive may be provided for a field of `List` type so that the backend can leverage technology such as asynchronous iterators to provide a partial list in the initial response, and additional list items in subsequent responses. `@include` and `@skip` take precedence over `@stream`. +directive @stream( + # If this argument label has a value other than null, it will be passed on to the result of this stream directive. This label is intended to give client applications a way to identify to which fragment a streamed result belongs to. + label: String + + # The initial elements that shall be send down to the consumer. + initialCount: Int! = 0 + + # Streamed when true. + if: Boolean +) on FIELD + +directive @authorize( + # The name of the authorization policy that determines access to the annotated resource. + policy: String + + # Roles that are allowed to access the annotated resource. + roles: [String!] + + # Defines when when the resolver shall be executed.By default the resolver is executed after the policy has determined that the current user is allowed to access the field. + apply: ApplyPolicy! = BEFORE_RESOLVER +) on SCHEMA | OBJECT | FIELD_DEFINITION + +# The UnsignedInt scalar type represents a unsigned 32-bit numeric non-fractional value greater than or equal to 0. +scalar UnsignedInt + +# The UnsignedShort scalar type represents a unsigned 16-bit numeric non-fractional value greater than or equal to 0. +scalar UnsignedShort + +type CloudLibNodeSetModel { + metadata: UANameSpaceMetadata + objectTypes( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: ObjectTypeModelFilterInput + order: [ObjectTypeModelSortInput!] + ): ObjectTypesConnection + variableTypes( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: VariableTypeModelFilterInput + order: [VariableTypeModelSortInput!] + ): VariableTypesConnection + dataTypes( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: DataTypeModelFilterInput + order: [DataTypeModelSortInput!] + ): DataTypesConnection + interfaces( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: InterfaceModelFilterInput + order: [InterfaceModelSortInput!] + ): InterfacesConnection + objects( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: ObjectModelFilterInput + order: [ObjectModelSortInput!] + ): ObjectsConnection + properties( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: PropertyModelFilterInput + order: [PropertyModelSortInput!] + ): PropertiesConnection + dataVariables( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: DataVariableModelFilterInput + order: [DataVariableModelSortInput!] + ): DataVariablesConnection + referenceTypes( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: ReferenceTypeModelFilterInput + order: [ReferenceTypeModelSortInput!] + ): ReferenceTypesConnection + unknownNodes( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: NodeModelFilterInput + order: [NodeModelSortInput!] + ): UnknownNodesConnection + validationStatus: ValidationStatus! + validationStatusInfo: String + validationErrors: [String] + modelUri: String + version: String + publicationDate: DateTime + requiredModels: [RequiredModelInfo] + identifier: String + allNodesByNodeId: [KeyValuePairOfStringAndNodeModel!] +} + +enum ApplyPolicy { + BEFORE_RESOLVER + AFTER_RESOLVER +} + +input ObjectTypeModelFilterInput { + and: [ObjectTypeModelFilterInput!] + or: [ObjectTypeModelFilterInput!] + nodesWithEvents: ListFilterInputTypeOfNodeModelFilterInput + isAbstract: BooleanOperationFilterInput + superType: BaseTypeModelFilterInput + subTypes: ListFilterInputTypeOfBaseTypeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input ObjectTypeModelSortInput { + isAbstract: SortEnumType + superType: BaseTypeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input VariableTypeModelFilterInput { + and: [VariableTypeModelFilterInput!] + or: [VariableTypeModelFilterInput!] + dataType: BaseTypeModelFilterInput + valueRank: ComparableNullableOfInt32OperationFilterInput + arrayDimensions: StringOperationFilterInput + value: StringOperationFilterInput + isAbstract: BooleanOperationFilterInput + superType: BaseTypeModelFilterInput + subTypes: ListFilterInputTypeOfBaseTypeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input VariableTypeModelSortInput { + dataType: BaseTypeModelSortInput + valueRank: SortEnumType + arrayDimensions: SortEnumType + value: SortEnumType + isAbstract: SortEnumType + superType: BaseTypeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input DataTypeModelFilterInput { + and: [DataTypeModelFilterInput!] + or: [DataTypeModelFilterInput!] + structureFields: ListFilterInputTypeOfStructureFieldFilterInput + enumFields: ListFilterInputTypeOfUaEnumFieldFilterInput + isOptionSet: BooleanOperationFilterInput + isAbstract: BooleanOperationFilterInput + superType: BaseTypeModelFilterInput + subTypes: ListFilterInputTypeOfBaseTypeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input DataTypeModelSortInput { + isOptionSet: SortEnumType + isAbstract: SortEnumType + superType: BaseTypeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input InterfaceModelFilterInput { + and: [InterfaceModelFilterInput!] + or: [InterfaceModelFilterInput!] + nodesWithInterface: ListFilterInputTypeOfNodeModelFilterInput + nodesWithEvents: ListFilterInputTypeOfNodeModelFilterInput + isAbstract: BooleanOperationFilterInput + superType: BaseTypeModelFilterInput + subTypes: ListFilterInputTypeOfBaseTypeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input InterfaceModelSortInput { + isAbstract: SortEnumType + superType: BaseTypeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input ObjectModelFilterInput { + and: [ObjectModelFilterInput!] + or: [ObjectModelFilterInput!] + nodesWithObjects: ListFilterInputTypeOfNodeModelFilterInput + typeDefinition: ObjectTypeModelFilterInput + modelingRule: StringOperationFilterInput + parent: NodeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input ObjectModelSortInput { + typeDefinition: ObjectTypeModelSortInput + modelingRule: SortEnumType + parent: NodeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input PropertyModelFilterInput { + and: [PropertyModelFilterInput!] + or: [PropertyModelFilterInput!] + dataType: BaseTypeModelFilterInput + valueRank: ComparableNullableOfInt32OperationFilterInput + arrayDimensions: StringOperationFilterInput + value: StringOperationFilterInput + nodesWithProperties: ListFilterInputTypeOfNodeModelFilterInput + engineeringUnit: EngineeringUnitInfoFilterInput + engUnitNodeId: StringOperationFilterInput + engUnitModelingRule: StringOperationFilterInput + engUnitAccessLevel: ComparableNullableOfUInt32OperationFilterInput + minValue: ComparableNullableOfDoubleOperationFilterInput + maxValue: ComparableNullableOfDoubleOperationFilterInput + eURangeNodeId: StringOperationFilterInput + eURangeModelingRule: StringOperationFilterInput + eURangeAccessLevel: ComparableNullableOfUInt32OperationFilterInput + instrumentMinValue: ComparableNullableOfDoubleOperationFilterInput + instrumentMaxValue: ComparableNullableOfDoubleOperationFilterInput + enumValue: ComparableNullableOfInt64OperationFilterInput + accessLevel: ComparableNullableOfUInt32OperationFilterInput + accessRestrictions: ComparableNullableOfUInt16OperationFilterInput + writeMask: ComparableNullableOfUInt32OperationFilterInput + userWriteMask: ComparableNullableOfUInt32OperationFilterInput + minimumSamplingInterval: ComparableNullableOfDoubleOperationFilterInput + typeDefinition: VariableTypeModelFilterInput + modelingRule: StringOperationFilterInput + parent: NodeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input PropertyModelSortInput { + dataType: BaseTypeModelSortInput + valueRank: SortEnumType + arrayDimensions: SortEnumType + value: SortEnumType + engineeringUnit: EngineeringUnitInfoSortInput + engUnitNodeId: SortEnumType + engUnitModelingRule: SortEnumType + engUnitAccessLevel: SortEnumType + minValue: SortEnumType + maxValue: SortEnumType + eURangeNodeId: SortEnumType + eURangeModelingRule: SortEnumType + eURangeAccessLevel: SortEnumType + instrumentMinValue: SortEnumType + instrumentMaxValue: SortEnumType + enumValue: SortEnumType + accessLevel: SortEnumType + accessRestrictions: SortEnumType + writeMask: SortEnumType + userWriteMask: SortEnumType + minimumSamplingInterval: SortEnumType + typeDefinition: VariableTypeModelSortInput + modelingRule: SortEnumType + parent: NodeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input DataVariableModelFilterInput { + and: [DataVariableModelFilterInput!] + or: [DataVariableModelFilterInput!] + nodesWithDataVariables: ListFilterInputTypeOfNodeModelFilterInput + dataType: BaseTypeModelFilterInput + valueRank: ComparableNullableOfInt32OperationFilterInput + arrayDimensions: StringOperationFilterInput + value: StringOperationFilterInput + nodesWithProperties: ListFilterInputTypeOfNodeModelFilterInput + engineeringUnit: EngineeringUnitInfoFilterInput + engUnitNodeId: StringOperationFilterInput + engUnitModelingRule: StringOperationFilterInput + engUnitAccessLevel: ComparableNullableOfUInt32OperationFilterInput + minValue: ComparableNullableOfDoubleOperationFilterInput + maxValue: ComparableNullableOfDoubleOperationFilterInput + eURangeNodeId: StringOperationFilterInput + eURangeModelingRule: StringOperationFilterInput + eURangeAccessLevel: ComparableNullableOfUInt32OperationFilterInput + instrumentMinValue: ComparableNullableOfDoubleOperationFilterInput + instrumentMaxValue: ComparableNullableOfDoubleOperationFilterInput + enumValue: ComparableNullableOfInt64OperationFilterInput + accessLevel: ComparableNullableOfUInt32OperationFilterInput + accessRestrictions: ComparableNullableOfUInt16OperationFilterInput + writeMask: ComparableNullableOfUInt32OperationFilterInput + userWriteMask: ComparableNullableOfUInt32OperationFilterInput + minimumSamplingInterval: ComparableNullableOfDoubleOperationFilterInput + typeDefinition: VariableTypeModelFilterInput + modelingRule: StringOperationFilterInput + parent: NodeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input DataVariableModelSortInput { + dataType: BaseTypeModelSortInput + valueRank: SortEnumType + arrayDimensions: SortEnumType + value: SortEnumType + engineeringUnit: EngineeringUnitInfoSortInput + engUnitNodeId: SortEnumType + engUnitModelingRule: SortEnumType + engUnitAccessLevel: SortEnumType + minValue: SortEnumType + maxValue: SortEnumType + eURangeNodeId: SortEnumType + eURangeModelingRule: SortEnumType + eURangeAccessLevel: SortEnumType + instrumentMinValue: SortEnumType + instrumentMaxValue: SortEnumType + enumValue: SortEnumType + accessLevel: SortEnumType + accessRestrictions: SortEnumType + writeMask: SortEnumType + userWriteMask: SortEnumType + minimumSamplingInterval: SortEnumType + typeDefinition: VariableTypeModelSortInput + modelingRule: SortEnumType + parent: NodeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input ReferenceTypeModelFilterInput { + and: [ReferenceTypeModelFilterInput!] + or: [ReferenceTypeModelFilterInput!] + inverseName: ListFilterInputTypeOfLocalizedTextFilterInput + symmetric: BooleanOperationFilterInput + isAbstract: BooleanOperationFilterInput + superType: BaseTypeModelFilterInput + subTypes: ListFilterInputTypeOfBaseTypeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input ReferenceTypeModelSortInput { + symmetric: SortEnumType + isAbstract: SortEnumType + superType: BaseTypeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input NodeModelFilterInput { + and: [NodeModelFilterInput!] + or: [NodeModelFilterInput!] + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input NodeModelSortInput { + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +# A connection to a list of items. +type ObjectTypesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [ObjectTypesEdge!] + + # A flattened list of the nodes. + nodes: [ObjectTypeModel] + totalCount: Int! +} + +# A connection to a list of items. +type VariableTypesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [VariableTypesEdge!] + + # A flattened list of the nodes. + nodes: [VariableTypeModel] + totalCount: Int! +} + +# A connection to a list of items. +type DataTypesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [DataTypesEdge!] + + # A flattened list of the nodes. + nodes: [DataTypeModel] + totalCount: Int! +} + +# A connection to a list of items. +type InterfacesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [InterfacesEdge!] + + # A flattened list of the nodes. + nodes: [InterfaceModel] + totalCount: Int! +} + +# A connection to a list of items. +type ObjectsConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [ObjectsEdge!] + + # A flattened list of the nodes. + nodes: [ObjectModel] + totalCount: Int! +} + +# A connection to a list of items. +type PropertiesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [PropertiesEdge!] + + # A flattened list of the nodes. + nodes: [PropertyModel] + totalCount: Int! +} + +# A connection to a list of items. +type DataVariablesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [DataVariablesEdge!] + + # A flattened list of the nodes. + nodes: [DataVariableModel] + totalCount: Int! +} + +# A connection to a list of items. +type ReferenceTypesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [ReferenceTypesEdge!] + + # A flattened list of the nodes. + nodes: [ReferenceTypeModel] + totalCount: Int! +} + +# A connection to a list of items. +type UnknownNodesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [UnknownNodesEdge!] + + # A flattened list of the nodes. + nodes: [NodeModel] + totalCount: Int! +} + +input ListFilterInputTypeOfNodeModelFilterInput { + all: NodeModelFilterInput + none: NodeModelFilterInput + some: NodeModelFilterInput + any: Boolean +} + +input BooleanOperationFilterInput { + eq: Boolean + neq: Boolean +} + +input BaseTypeModelFilterInput { + and: [BaseTypeModelFilterInput!] + or: [BaseTypeModelFilterInput!] + isAbstract: BooleanOperationFilterInput + superType: BaseTypeModelFilterInput + subTypes: ListFilterInputTypeOfBaseTypeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input ListFilterInputTypeOfBaseTypeModelFilterInput { + all: BaseTypeModelFilterInput + none: BaseTypeModelFilterInput + some: BaseTypeModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfLocalizedTextFilterInput { + all: LocalizedTextFilterInput + none: LocalizedTextFilterInput + some: LocalizedTextFilterInput + any: Boolean +} + +input StringOperationFilterInput { + and: [StringOperationFilterInput!] + or: [StringOperationFilterInput!] + eq: String + neq: String + contains: String + ncontains: String + in: [String] + nin: [String] + startsWith: String + nstartsWith: String + endsWith: String + nendsWith: String +} + +input ListStringOperationFilterInput { + all: StringOperationFilterInput + none: StringOperationFilterInput + some: StringOperationFilterInput + any: Boolean +} + +input NodeSetModelFilterInput { + and: [NodeSetModelFilterInput!] + or: [NodeSetModelFilterInput!] + modelUri: StringOperationFilterInput + version: StringOperationFilterInput + publicationDate: ComparableNullableOfDateTimeOperationFilterInput + requiredModels: ListFilterInputTypeOfRequiredModelInfoFilterInput + identifier: StringOperationFilterInput + objectTypes: ListFilterInputTypeOfObjectTypeModelFilterInput + variableTypes: ListFilterInputTypeOfVariableTypeModelFilterInput + dataTypes: ListFilterInputTypeOfDataTypeModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + properties: ListFilterInputTypeOfPropertyModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + unknownNodes: ListFilterInputTypeOfNodeModelFilterInput + referenceTypes: ListFilterInputTypeOfReferenceTypeModelFilterInput + allNodesByNodeId: DictionaryOfStringAndNodeModelFilterInput +} + +input ListFilterInputTypeOfVariableModelFilterInput { + all: VariableModelFilterInput + none: VariableModelFilterInput + some: VariableModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfDataVariableModelFilterInput { + all: DataVariableModelFilterInput + none: DataVariableModelFilterInput + some: DataVariableModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfObjectModelFilterInput { + all: ObjectModelFilterInput + none: ObjectModelFilterInput + some: ObjectModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfInterfaceModelFilterInput { + all: InterfaceModelFilterInput + none: InterfaceModelFilterInput + some: InterfaceModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfMethodModelFilterInput { + all: MethodModelFilterInput + none: MethodModelFilterInput + some: MethodModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfObjectTypeModelFilterInput { + all: ObjectTypeModelFilterInput + none: ObjectTypeModelFilterInput + some: ObjectTypeModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfNodeAndReferenceFilterInput { + all: NodeAndReferenceFilterInput + none: NodeAndReferenceFilterInput + some: NodeAndReferenceFilterInput + any: Boolean +} + +enum SortEnumType { + ASC + DESC +} + +input BaseTypeModelSortInput { + isAbstract: SortEnumType + superType: BaseTypeModelSortInput + browseName: SortEnumType + symbolicName: SortEnumType + documentation: SortEnumType + releaseStatus: SortEnumType + namespace: SortEnumType + nodeId: SortEnumType + nodeSet: NodeSetModelSortInput +} + +input NodeSetModelSortInput { + modelUri: SortEnumType + version: SortEnumType + publicationDate: SortEnumType + identifier: SortEnumType +} + +input ComparableNullableOfInt32OperationFilterInput { + eq: Int + neq: Int + in: [Int] + nin: [Int] + gt: Int + ngt: Int + gte: Int + ngte: Int + lt: Int + nlt: Int + lte: Int + nlte: Int +} + +input ListFilterInputTypeOfStructureFieldFilterInput { + all: StructureFieldFilterInput + none: StructureFieldFilterInput + some: StructureFieldFilterInput + any: Boolean +} + +input ListFilterInputTypeOfUaEnumFieldFilterInput { + all: UaEnumFieldFilterInput + none: UaEnumFieldFilterInput + some: UaEnumFieldFilterInput + any: Boolean +} + +input EngineeringUnitInfoFilterInput { + and: [EngineeringUnitInfoFilterInput!] + or: [EngineeringUnitInfoFilterInput!] + displayName: LocalizedTextFilterInput + description: LocalizedTextFilterInput + namespaceUri: StringOperationFilterInput + unitId: ComparableNullableOfInt32OperationFilterInput +} + +input ComparableNullableOfUInt32OperationFilterInput { + eq: UnsignedInt + neq: UnsignedInt + in: [UnsignedInt] + nin: [UnsignedInt] + gt: UnsignedInt + ngt: UnsignedInt + gte: UnsignedInt + ngte: UnsignedInt + lt: UnsignedInt + nlt: UnsignedInt + lte: UnsignedInt + nlte: UnsignedInt +} + +input ComparableNullableOfDoubleOperationFilterInput { + eq: Float + neq: Float + in: [Float] + nin: [Float] + gt: Float + ngt: Float + gte: Float + ngte: Float + lt: Float + nlt: Float + lte: Float + nlte: Float +} + +input ComparableNullableOfInt64OperationFilterInput { + eq: Long + neq: Long + in: [Long] + nin: [Long] + gt: Long + ngt: Long + gte: Long + ngte: Long + lt: Long + nlt: Long + lte: Long + nlte: Long +} + +input ComparableNullableOfUInt16OperationFilterInput { + eq: UnsignedShort + neq: UnsignedShort + in: [UnsignedShort] + nin: [UnsignedShort] + gt: UnsignedShort + ngt: UnsignedShort + gte: UnsignedShort + ngte: UnsignedShort + lt: UnsignedShort + nlt: UnsignedShort + lte: UnsignedShort + nlte: UnsignedShort +} + +input EngineeringUnitInfoSortInput { + displayName: LocalizedTextSortInput + description: LocalizedTextSortInput + namespaceUri: SortEnumType + unitId: SortEnumType +} + +# Information about pagination in a connection. +type PageInfo { + # Indicates whether more edges exist following the set defined by the clients arguments. + hasNextPage: Boolean! + + # Indicates whether more edges exist prior the set defined by the clients arguments. + hasPreviousPage: Boolean! + + # When paginating backwards, the cursor to continue. + startCursor: String + + # When paginating forwards, the cursor to continue. + endCursor: String +} + +type ObjectTypeModel { + hasBaseType(nodeId: String): Boolean! + browseName: String + nodesWithEvents: [NodeModel] + isAbstract: Boolean! + superType: BaseTypeModel + subTypes: [BaseTypeModel] + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type ObjectTypesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: ObjectTypeModel +} + +type VariableTypeModel { + hasBaseType(nodeId: String): Boolean! + browseName: String + dataType: BaseTypeModel + valueRank: Int + arrayDimensions: String + value: String + isAbstract: Boolean! + superType: BaseTypeModel + subTypes: [BaseTypeModel] + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type VariableTypesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: VariableTypeModel +} + +type DataTypeModel { + hasBaseType(nodeId: String): Boolean! + browseName: String + structureFields: [StructureField] + enumFields: [UaEnumField] + isOptionSet: Boolean + isAbstract: Boolean! + superType: BaseTypeModel + subTypes: [BaseTypeModel] + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type DataTypesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: DataTypeModel +} + +type InterfaceModel { + hasBaseType(nodeId: String): Boolean! + browseName: String + nodesWithInterface: [NodeModel] + nodesWithEvents: [NodeModel] + isAbstract: Boolean! + superType: BaseTypeModel + subTypes: [BaseTypeModel] + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type InterfacesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: InterfaceModel +} + +type ObjectModel { + browseName: String + nodesWithObjects: [NodeModel] + typeDefinition: ObjectTypeModel + modelingRule: String + parent: NodeModel + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type ObjectsEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: ObjectModel +} + +type PropertyModel { + browseName: String + dataType: BaseTypeModel + valueRank: Int + arrayDimensions: String + value: String + nodesWithProperties: [NodeModel] + engineeringUnit: EngineeringUnitInfo + engUnitNodeId: String + engUnitModelingRule: String + engUnitAccessLevel: UnsignedInt + minValue: Float + maxValue: Float + eURangeNodeId: String + eURangeModelingRule: String + eURangeAccessLevel: UnsignedInt + instrumentMinValue: Float + instrumentMaxValue: Float + enumValue: Long + accessLevel: UnsignedInt + accessRestrictions: UnsignedShort + writeMask: UnsignedInt + userWriteMask: UnsignedInt + minimumSamplingInterval: Float + typeDefinition: VariableTypeModel + modelingRule: String + parent: NodeModel + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type PropertiesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: PropertyModel +} + +type DataVariableModel { + browseName: String + nodesWithDataVariables: [NodeModel] + dataType: BaseTypeModel + valueRank: Int + arrayDimensions: String + value: String + nodesWithProperties: [NodeModel] + engineeringUnit: EngineeringUnitInfo + engUnitNodeId: String + engUnitModelingRule: String + engUnitAccessLevel: UnsignedInt + minValue: Float + maxValue: Float + eURangeNodeId: String + eURangeModelingRule: String + eURangeAccessLevel: UnsignedInt + instrumentMinValue: Float + instrumentMaxValue: Float + enumValue: Long + accessLevel: UnsignedInt + accessRestrictions: UnsignedShort + writeMask: UnsignedInt + userWriteMask: UnsignedInt + minimumSamplingInterval: Float + typeDefinition: VariableTypeModel + modelingRule: String + parent: NodeModel + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type DataVariablesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: DataVariableModel +} + +type ReferenceTypeModel { + hasBaseType(nodeId: String): Boolean! + browseName: String + inverseName: [LocalizedText] + symmetric: Boolean! + isAbstract: Boolean! + superType: BaseTypeModel + subTypes: [BaseTypeModel] + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type ReferenceTypesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: ReferenceTypeModel +} + +type NodeModel { + browseName: String + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +# An edge in a connection. +type UnknownNodesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: NodeModel +} + +input LocalizedTextFilterInput { + and: [LocalizedTextFilterInput!] + or: [LocalizedTextFilterInput!] + text: StringOperationFilterInput + locale: StringOperationFilterInput +} + +input ComparableNullableOfDateTimeOperationFilterInput { + eq: DateTime + neq: DateTime + in: [DateTime] + nin: [DateTime] + gt: DateTime + ngt: DateTime + gte: DateTime + ngte: DateTime + lt: DateTime + nlt: DateTime + lte: DateTime + nlte: DateTime +} + +input ListFilterInputTypeOfRequiredModelInfoFilterInput { + all: RequiredModelInfoFilterInput + none: RequiredModelInfoFilterInput + some: RequiredModelInfoFilterInput + any: Boolean +} + +input ListFilterInputTypeOfVariableTypeModelFilterInput { + all: VariableTypeModelFilterInput + none: VariableTypeModelFilterInput + some: VariableTypeModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfDataTypeModelFilterInput { + all: DataTypeModelFilterInput + none: DataTypeModelFilterInput + some: DataTypeModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfPropertyModelFilterInput { + all: PropertyModelFilterInput + none: PropertyModelFilterInput + some: PropertyModelFilterInput + any: Boolean +} + +input ListFilterInputTypeOfReferenceTypeModelFilterInput { + all: ReferenceTypeModelFilterInput + none: ReferenceTypeModelFilterInput + some: ReferenceTypeModelFilterInput + any: Boolean +} + +input DictionaryOfStringAndNodeModelFilterInput { + and: [DictionaryOfStringAndNodeModelFilterInput!] + or: [DictionaryOfStringAndNodeModelFilterInput!] + comparer: IEqualityComparerOfStringFilterInput + count: ComparableInt32OperationFilterInput + keys: ListStringOperationFilterInput + values: ListFilterInputTypeOfNodeModelFilterInput +} + +input VariableModelFilterInput { + and: [VariableModelFilterInput!] + or: [VariableModelFilterInput!] + dataType: BaseTypeModelFilterInput + valueRank: ComparableNullableOfInt32OperationFilterInput + arrayDimensions: StringOperationFilterInput + value: StringOperationFilterInput + nodesWithProperties: ListFilterInputTypeOfNodeModelFilterInput + engineeringUnit: EngineeringUnitInfoFilterInput + engUnitNodeId: StringOperationFilterInput + engUnitModelingRule: StringOperationFilterInput + engUnitAccessLevel: ComparableNullableOfUInt32OperationFilterInput + minValue: ComparableNullableOfDoubleOperationFilterInput + maxValue: ComparableNullableOfDoubleOperationFilterInput + eURangeNodeId: StringOperationFilterInput + eURangeModelingRule: StringOperationFilterInput + eURangeAccessLevel: ComparableNullableOfUInt32OperationFilterInput + instrumentMinValue: ComparableNullableOfDoubleOperationFilterInput + instrumentMaxValue: ComparableNullableOfDoubleOperationFilterInput + enumValue: ComparableNullableOfInt64OperationFilterInput + accessLevel: ComparableNullableOfUInt32OperationFilterInput + accessRestrictions: ComparableNullableOfUInt16OperationFilterInput + writeMask: ComparableNullableOfUInt32OperationFilterInput + userWriteMask: ComparableNullableOfUInt32OperationFilterInput + minimumSamplingInterval: ComparableNullableOfDoubleOperationFilterInput + typeDefinition: VariableTypeModelFilterInput + modelingRule: StringOperationFilterInput + parent: NodeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input MethodModelFilterInput { + and: [MethodModelFilterInput!] + or: [MethodModelFilterInput!] + nodesWithMethods: ListFilterInputTypeOfNodeModelFilterInput + typeDefinition: MethodModelFilterInput + modelingRule: StringOperationFilterInput + parent: NodeModelFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + browseName: StringOperationFilterInput + symbolicName: StringOperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + documentation: StringOperationFilterInput + releaseStatus: StringOperationFilterInput + namespace: StringOperationFilterInput + nodeId: StringOperationFilterInput + categories: ListStringOperationFilterInput + nodeSet: NodeSetModelFilterInput + properties: ListFilterInputTypeOfVariableModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + methods: ListFilterInputTypeOfMethodModelFilterInput + events: ListFilterInputTypeOfObjectTypeModelFilterInput + otherReferencedNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput + otherReferencingNodes: ListFilterInputTypeOfNodeAndReferenceFilterInput +} + +input NodeAndReferenceFilterInput { + and: [NodeAndReferenceFilterInput!] + or: [NodeAndReferenceFilterInput!] + node: NodeModelFilterInput + reference: StringOperationFilterInput +} + +input StructureFieldFilterInput { + and: [StructureFieldFilterInput!] + or: [StructureFieldFilterInput!] + name: StringOperationFilterInput + dataType: BaseTypeModelFilterInput + valueRank: ComparableNullableOfInt32OperationFilterInput + arrayDimensions: StringOperationFilterInput + maxStringLength: ComparableNullableOfUInt32OperationFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + isOptional: BooleanOperationFilterInput + fieldOrder: ComparableInt32OperationFilterInput +} + +input UaEnumFieldFilterInput { + and: [UaEnumFieldFilterInput!] + or: [UaEnumFieldFilterInput!] + name: StringOperationFilterInput + displayName: ListFilterInputTypeOfLocalizedTextFilterInput + description: ListFilterInputTypeOfLocalizedTextFilterInput + value: ComparableInt64OperationFilterInput +} + +input LocalizedTextSortInput { + text: SortEnumType + locale: SortEnumType +} + +input RequiredModelInfoFilterInput { + and: [RequiredModelInfoFilterInput!] + or: [RequiredModelInfoFilterInput!] + modelUri: StringOperationFilterInput + version: StringOperationFilterInput + publicationDate: ComparableNullableOfDateTimeOperationFilterInput + availableModel: NodeSetModelFilterInput +} + +input IEqualityComparerOfStringFilterInput { + and: [IEqualityComparerOfStringFilterInput!] + or: [IEqualityComparerOfStringFilterInput!] +} + +input ComparableInt32OperationFilterInput { + eq: Int + neq: Int + in: [Int!] + nin: [Int!] + gt: Int + ngt: Int + gte: Int + ngte: Int + lt: Int + nlt: Int + lte: Int + nlte: Int +} + +input ComparableInt64OperationFilterInput { + eq: Long + neq: Long + in: [Long!] + nin: [Long!] + gt: Long + ngt: Long + gte: Long + ngte: Long + lt: Long + nlt: Long + lte: Long + nlte: Long +} + +type QueryModel { + nodeSets( + identifier: String + nodeSetUrl: String + publicationDate: DateTime + keywords: [String] + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: CloudLibNodeSetModelFilterInput + order: [CloudLibNodeSetModelSortInput!] + ): NodeSetsConnection + objectTypes( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: ObjectTypeModelFilterInput + order: [ObjectTypeModelSortInput!] + ): ObjectTypesConnection + variableTypes( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: VariableTypeModelFilterInput + order: [VariableTypeModelSortInput!] + ): VariableTypesConnection + dataTypes( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: DataTypeModelFilterInput + order: [DataTypeModelSortInput!] + ): DataTypesConnection + properties( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: PropertyModelFilterInput + order: [PropertyModelSortInput!] + ): PropertiesConnection + dataVariables( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: DataVariableModelFilterInput + order: [DataVariableModelSortInput!] + ): DataVariablesConnection + referenceTypes( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: ReferenceTypeModelFilterInput + order: [ReferenceTypeModelSortInput!] + ): ReferenceTypesConnection + interfaces( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: InterfaceModelFilterInput + order: [InterfaceModelSortInput!] + ): InterfacesConnection + objects( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: ObjectModelFilterInput + order: [ObjectModelSortInput!] + ): ObjectsConnection + allNodes( + nodeSetUrl: String + publicationDate: DateTime + nodeId: String + + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: NodeModelFilterInput + order: [NodeModelSortInput!] + ): AllNodesConnection + categories( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: CategoryFilterInput + order: [CategorySortInput!] + ): CategoriesConnection + organisations( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: OrganisationFilterInput + order: [OrganisationSortInput!] + ): OrganisationsConnection + namespaces( + # Returns the first _n_ elements from the list. + first: Int + + # Returns the elements in the list that come after the specified cursor. + after: String + + # Returns the last _n_ elements from the list. + last: Int + + # Returns the elements in the list that come before the specified cursor. + before: String + where: UANameSpaceFilterInput + order: [UANameSpaceSortInput!] + ): NamespacesConnection @deprecated(reason: "Use NodeSets.Metadata instead.") + nameSpace( + limit: Int! + offset: Int! + where: String + orderBy: String + ): [UANameSpace] @deprecated(reason: "Use namespaces instead.") + category( + limit: Int! + offset: Int! + where: String + orderBy: String + ): [Category] @deprecated(reason: "Use categories instead.") + metadata: [MetadataModel] + @deprecated( + reason: "Use namespaces and namespaces.additionalProperties instead." + ) + organisation( + limit: Int! + offset: Int! + where: String + orderBy: String + ): [Organisation] @deprecated(reason: "Use organizations instead.") + nodeSet: [NodeSetGraphQLLegacy] @deprecated(reason: "Use nodeSets instead.") + objectType: [ObjecttypeModel] @deprecated(reason: "Use objectTypes instead.") + dataType: [DatatypeModel] @deprecated(reason: "Use dataTypes instead.") + referenceType: [ReferencetypeModel] + @deprecated(reason: "Use referenceTypes instead.") + variableType: [VariabletypeModel] + @deprecated(reason: "Use variableTypes instead.") +} + +type UANameSpaceMetadata { + title: String! + contributor: Organisation! + license: License! + copyrightText: String! + description: String! + category: Category! + documentationUrl: URL + iconUrl: URL + licenseUrl: URL + keywords: [String] + purchasingInformationUrl: URL + releaseNotesUrl: URL + testSpecificationUrl: URL + supportedLocales: [String] + numberOfDownloads: UnsignedInt! + validationStatus: String + additionalProperties: [UAProperty] +} + +enum ValidationStatus { + PARSED + INDEXED + ERROR +} + +# The `DateTime` scalar represents an ISO-8601 compliant date time type. +scalar DateTime + +type RequiredModelInfo { + modelUri: String + version: String + publicationDate: DateTime + availableModel: NodeSetModel +} + +type KeyValuePairOfStringAndNodeModel { + key: String! + value: NodeModel! +} + +# The `Long` scalar type represents non-fractional signed whole 64-bit numeric values. Long can represent values between -(2^63) and 2^63 - 1. +scalar Long + +type BaseTypeModel { + hasBaseType(nodeId: String): Boolean! + browseName: String + isAbstract: Boolean! + superType: BaseTypeModel + subTypes: [BaseTypeModel] + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +type LocalizedText { + text: String! + locale: String +} + +type NodeSetModel { + modelUri: String + version: String + publicationDate: DateTime + requiredModels: [RequiredModelInfo] + identifier: String + objectTypes: [ObjectTypeModel] + variableTypes: [VariableTypeModel] + dataTypes: [DataTypeModel] + interfaces: [InterfaceModel] + objects: [ObjectModel] + properties: [PropertyModel] + dataVariables: [DataVariableModel] + unknownNodes: [NodeModel] + referenceTypes: [ReferenceTypeModel] + allNodesByNodeId: [KeyValuePairOfStringAndNodeModel!] +} + +type VariableModel { + browseName: String + dataType: BaseTypeModel + valueRank: Int + arrayDimensions: String + value: String + nodesWithProperties: [NodeModel] + engineeringUnit: EngineeringUnitInfo + engUnitNodeId: String + engUnitModelingRule: String + engUnitAccessLevel: UnsignedInt + minValue: Float + maxValue: Float + eURangeNodeId: String + eURangeModelingRule: String + eURangeAccessLevel: UnsignedInt + instrumentMinValue: Float + instrumentMaxValue: Float + enumValue: Long + accessLevel: UnsignedInt + accessRestrictions: UnsignedShort + writeMask: UnsignedInt + userWriteMask: UnsignedInt + minimumSamplingInterval: Float + typeDefinition: VariableTypeModel + modelingRule: String + parent: NodeModel + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +type MethodModel { + browseName: String + nodesWithMethods: [NodeModel] + typeDefinition: MethodModel + modelingRule: String + parent: NodeModel + displayName: [LocalizedText] + symbolicName: String + description: [LocalizedText] + documentation: String + releaseStatus: String + namespace: String + nodeId: String + categories: [String] + nodeSet: NodeSetModel + properties: [VariableModel] + dataVariables: [DataVariableModel] + objects: [ObjectModel] + interfaces: [InterfaceModel] + methods: [MethodModel] + events: [ObjectTypeModel] + otherReferencedNodes: [NodeAndReference] + otherReferencingNodes: [NodeAndReference] +} + +type NodeAndReference { + node: NodeModel + reference: String +} + +type StructureField { + name: String + dataType: BaseTypeModel + valueRank: Int + arrayDimensions: String + maxStringLength: UnsignedInt + description: [LocalizedText] + isOptional: Boolean! + fieldOrder: Int! +} + +type UaEnumField { + name: String + displayName: [LocalizedText] + description: [LocalizedText] + value: Long! +} + +type EngineeringUnitInfo { + displayName: LocalizedText + description: LocalizedText + namespaceUri: String + unitId: Int +} + +input CloudLibNodeSetModelFilterInput { + and: [CloudLibNodeSetModelFilterInput!] + or: [CloudLibNodeSetModelFilterInput!] + metadata: UANameSpaceMetadataFilterInput + validationStatus: ValidationStatusOperationFilterInput + validationStatusInfo: StringOperationFilterInput + validationElapsedTime: ComparableTimeSpanOperationFilterInput + validationFinishedTime: ComparableNullableOfDateTimeOperationFilterInput + validationErrors: ListStringOperationFilterInput + modelUri: StringOperationFilterInput + version: StringOperationFilterInput + publicationDate: ComparableNullableOfDateTimeOperationFilterInput + requiredModels: ListFilterInputTypeOfRequiredModelInfoFilterInput + identifier: StringOperationFilterInput + objectTypes: ListFilterInputTypeOfObjectTypeModelFilterInput + variableTypes: ListFilterInputTypeOfVariableTypeModelFilterInput + dataTypes: ListFilterInputTypeOfDataTypeModelFilterInput + interfaces: ListFilterInputTypeOfInterfaceModelFilterInput + objects: ListFilterInputTypeOfObjectModelFilterInput + properties: ListFilterInputTypeOfPropertyModelFilterInput + dataVariables: ListFilterInputTypeOfDataVariableModelFilterInput + unknownNodes: ListFilterInputTypeOfNodeModelFilterInput + referenceTypes: ListFilterInputTypeOfReferenceTypeModelFilterInput + allNodesByNodeId: DictionaryOfStringAndNodeModelFilterInput +} + +input CloudLibNodeSetModelSortInput { + metadata: UANameSpaceMetadataSortInput + validationStatus: SortEnumType + validationStatusInfo: SortEnumType + validationElapsedTime: SortEnumType + validationFinishedTime: SortEnumType + modelUri: SortEnumType + version: SortEnumType + publicationDate: SortEnumType + identifier: SortEnumType +} + +input CategoryFilterInput { + and: [CategoryFilterInput!] + or: [CategoryFilterInput!] + name: StringOperationFilterInput + description: StringOperationFilterInput + iconUrl: UriFilterInput +} + +input CategorySortInput { + name: SortEnumType + description: SortEnumType + iconUrl: UriSortInput +} + +input OrganisationFilterInput { + and: [OrganisationFilterInput!] + or: [OrganisationFilterInput!] + name: StringOperationFilterInput + description: StringOperationFilterInput + logoUrl: UriFilterInput + contactEmail: StringOperationFilterInput + website: UriFilterInput +} + +input OrganisationSortInput { + name: SortEnumType + description: SortEnumType + logoUrl: UriSortInput + contactEmail: SortEnumType + website: UriSortInput +} + +input UANameSpaceFilterInput { + and: [UANameSpaceFilterInput!] + or: [UANameSpaceFilterInput!] + nodeset: NodesetFilterInput + title: StringOperationFilterInput + contributor: OrganisationFilterInput + license: LicenseOperationFilterInput + copyrightText: StringOperationFilterInput + description: StringOperationFilterInput + category: CategoryFilterInput + documentationUrl: UriFilterInput + iconUrl: UriFilterInput + licenseUrl: UriFilterInput + keywords: ListStringOperationFilterInput + purchasingInformationUrl: UriFilterInput + releaseNotesUrl: UriFilterInput + testSpecificationUrl: UriFilterInput + supportedLocales: ListStringOperationFilterInput + numberOfDownloads: ComparableUInt32OperationFilterInput + validationStatus: StringOperationFilterInput + additionalProperties: ListFilterInputTypeOfUAPropertyFilterInput +} + +input UANameSpaceSortInput { + nodeset: NodesetSortInput + title: SortEnumType + contributor: OrganisationSortInput + license: SortEnumType + copyrightText: SortEnumType + description: SortEnumType + category: CategorySortInput + documentationUrl: UriSortInput + iconUrl: UriSortInput + licenseUrl: UriSortInput + purchasingInformationUrl: UriSortInput + releaseNotesUrl: UriSortInput + testSpecificationUrl: UriSortInput + numberOfDownloads: SortEnumType + validationStatus: SortEnumType +} + +# A connection to a list of items. +type NodeSetsConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [NodeSetsEdge!] + + # A flattened list of the nodes. + nodes: [CloudLibNodeSetModel] + totalCount: Int! +} + +# A connection to a list of items. +type AllNodesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [AllNodesEdge!] + + # A flattened list of the nodes. + nodes: [NodeModel] + totalCount: Int! +} + +# A connection to a list of items. +type CategoriesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [CategoriesEdge!] + + # A flattened list of the nodes. + nodes: [Category] + totalCount: Int! +} + +# A connection to a list of items. +type OrganisationsConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [OrganisationsEdge!] + + # A flattened list of the nodes. + nodes: [Organisation] + totalCount: Int! +} + +# A connection to a list of items. +type NamespacesConnection { + # Information to aid in pagination. + pageInfo: PageInfo! + + # A list of edges. + edges: [NamespacesEdge!] + + # A flattened list of the nodes. + nodes: [UANameSpace] + totalCount: Int! +} + +input UANameSpaceMetadataFilterInput { + and: [UANameSpaceMetadataFilterInput!] + or: [UANameSpaceMetadataFilterInput!] + title: StringOperationFilterInput + contributor: OrganisationFilterInput + license: LicenseOperationFilterInput + copyrightText: StringOperationFilterInput + description: StringOperationFilterInput + category: CategoryFilterInput + documentationUrl: UriFilterInput + iconUrl: UriFilterInput + licenseUrl: UriFilterInput + keywords: ListStringOperationFilterInput + purchasingInformationUrl: UriFilterInput + releaseNotesUrl: UriFilterInput + testSpecificationUrl: UriFilterInput + supportedLocales: ListStringOperationFilterInput + numberOfDownloads: ComparableUInt32OperationFilterInput + validationStatus: StringOperationFilterInput + additionalProperties: ListFilterInputTypeOfUAPropertyFilterInput +} + +input ValidationStatusOperationFilterInput { + eq: ValidationStatus + neq: ValidationStatus + in: [ValidationStatus!] + nin: [ValidationStatus!] +} + +input ComparableTimeSpanOperationFilterInput { + eq: TimeSpan + neq: TimeSpan + in: [TimeSpan!] + nin: [TimeSpan!] + gt: TimeSpan + ngt: TimeSpan + gte: TimeSpan + ngte: TimeSpan + lt: TimeSpan + nlt: TimeSpan + lte: TimeSpan + nlte: TimeSpan +} + +input UANameSpaceMetadataSortInput { + title: SortEnumType + contributor: OrganisationSortInput + license: SortEnumType + copyrightText: SortEnumType + description: SortEnumType + category: CategorySortInput + documentationUrl: UriSortInput + iconUrl: UriSortInput + licenseUrl: UriSortInput + purchasingInformationUrl: UriSortInput + releaseNotesUrl: UriSortInput + testSpecificationUrl: UriSortInput + numberOfDownloads: SortEnumType + validationStatus: SortEnumType +} + +input UriFilterInput { + and: [UriFilterInput!] + or: [UriFilterInput!] + absolutePath: StringOperationFilterInput + absoluteUri: StringOperationFilterInput + localPath: StringOperationFilterInput + authority: StringOperationFilterInput + hostNameType: UriHostNameTypeOperationFilterInput + isDefaultPort: BooleanOperationFilterInput + isFile: BooleanOperationFilterInput + isLoopback: BooleanOperationFilterInput + pathAndQuery: StringOperationFilterInput + segments: ListStringOperationFilterInput + isUnc: BooleanOperationFilterInput + host: StringOperationFilterInput + port: ComparableInt32OperationFilterInput + query: StringOperationFilterInput + fragment: StringOperationFilterInput + scheme: StringOperationFilterInput + originalString: StringOperationFilterInput + dnsSafeHost: StringOperationFilterInput + idnHost: StringOperationFilterInput + isAbsoluteUri: BooleanOperationFilterInput + userEscaped: BooleanOperationFilterInput + userInfo: StringOperationFilterInput +} + +input UriSortInput { + absolutePath: SortEnumType + absoluteUri: SortEnumType + localPath: SortEnumType + authority: SortEnumType + hostNameType: SortEnumType + isDefaultPort: SortEnumType + isFile: SortEnumType + isLoopback: SortEnumType + pathAndQuery: SortEnumType + isUnc: SortEnumType + host: SortEnumType + port: SortEnumType + query: SortEnumType + fragment: SortEnumType + scheme: SortEnumType + originalString: SortEnumType + dnsSafeHost: SortEnumType + idnHost: SortEnumType + isAbsoluteUri: SortEnumType + userEscaped: SortEnumType + userInfo: SortEnumType +} + +input NodesetFilterInput { + and: [NodesetFilterInput!] + or: [NodesetFilterInput!] + nodesetXml: StringOperationFilterInput + identifier: ComparableUInt32OperationFilterInput + namespaceUri: UriFilterInput + version: StringOperationFilterInput + publicationDate: ComparableDateTimeOperationFilterInput + lastModifiedDate: ComparableDateTimeOperationFilterInput + validationStatus: StringOperationFilterInput + requiredModels: ListFilterInputTypeOfCloudLibRequiredModelInfoFilterInput +} + +input LicenseOperationFilterInput { + eq: License + neq: License + in: [License!] + nin: [License!] +} + +input ComparableUInt32OperationFilterInput { + eq: UnsignedInt + neq: UnsignedInt + in: [UnsignedInt!] + nin: [UnsignedInt!] + gt: UnsignedInt + ngt: UnsignedInt + gte: UnsignedInt + ngte: UnsignedInt + lt: UnsignedInt + nlt: UnsignedInt + lte: UnsignedInt + nlte: UnsignedInt +} + +input ListFilterInputTypeOfUAPropertyFilterInput { + all: UAPropertyFilterInput + none: UAPropertyFilterInput + some: UAPropertyFilterInput + any: Boolean +} + +input NodesetSortInput { + nodesetXml: SortEnumType + identifier: SortEnumType + namespaceUri: UriSortInput + version: SortEnumType + publicationDate: SortEnumType + lastModifiedDate: SortEnumType + validationStatus: SortEnumType +} + +# An edge in a connection. +type NodeSetsEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: CloudLibNodeSetModel +} + +# An edge in a connection. +type AllNodesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: NodeModel +} + +type Category { + name: String! + description: String + iconUrl: URL +} + +# An edge in a connection. +type CategoriesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: Category +} + +type Organisation { + name: String! + description: String + logoUrl: URL + contactEmail: String + website: URL +} + +# An edge in a connection. +type OrganisationsEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: Organisation +} + +type UANameSpace { + nodeset: Nodeset! + title: String! + contributor: Organisation! + license: License! + copyrightText: String! + description: String! + category: Category! + documentationUrl: URL + iconUrl: URL + licenseUrl: URL + keywords: [String] + purchasingInformationUrl: URL + releaseNotesUrl: URL + testSpecificationUrl: URL + supportedLocales: [String] + numberOfDownloads: UnsignedInt! + validationStatus: String + additionalProperties: [UAProperty] +} + +# An edge in a connection. +type NamespacesEdge { + # A cursor for use in pagination. + cursor: String! + + # The item at the end of the edge. + node: UANameSpace +} + +input UriHostNameTypeOperationFilterInput { + eq: UriHostNameType + neq: UriHostNameType + in: [UriHostNameType!] + nin: [UriHostNameType!] +} + +input ComparableDateTimeOperationFilterInput { + eq: DateTime + neq: DateTime + in: [DateTime!] + nin: [DateTime!] + gt: DateTime + ngt: DateTime + gte: DateTime + ngte: DateTime + lt: DateTime + nlt: DateTime + lte: DateTime + nlte: DateTime +} + +input ListFilterInputTypeOfCloudLibRequiredModelInfoFilterInput { + all: CloudLibRequiredModelInfoFilterInput + none: CloudLibRequiredModelInfoFilterInput + some: CloudLibRequiredModelInfoFilterInput + any: Boolean +} + +input UAPropertyFilterInput { + and: [UAPropertyFilterInput!] + or: [UAPropertyFilterInput!] + name: StringOperationFilterInput + value: StringOperationFilterInput +} + +input CloudLibRequiredModelInfoFilterInput { + and: [CloudLibRequiredModelInfoFilterInput!] + or: [CloudLibRequiredModelInfoFilterInput!] + namespaceUri: StringOperationFilterInput + publicationDate: ComparableNullableOfDateTimeOperationFilterInput + version: StringOperationFilterInput + availableModel: NodesetFilterInput +} + +enum UriHostNameType { + UNKNOWN + BASIC + DNS + I_PV4 + I_PV6 +} + +type UAProperty { + name: String + value: String +} + +scalar URL + +enum License { + MIT + APACHE_LICENSE20 + CUSTOM +} + +type VariabletypeModel { + id: Int! + nodesetId: Long! + browseName: String + value: String + nameSpace: String +} + +type ReferencetypeModel { + id: Int! + nodesetId: Long! + browseName: String + value: String + nameSpace: String +} + +type DatatypeModel { + id: Int! + nodesetId: Long! + browseName: String + value: String + nameSpace: String +} + +type ObjecttypeModel { + id: Int! + nodesetId: Long! + browseName: String + value: String + nameSpace: String +} + +type NodeSetGraphQLLegacy { + nodesetXml: String + identifier: UnsignedInt! + namespaceUri: String + version: String + publicationDate: DateTime! + lastModifiedDate: DateTime! +} + +type MetadataModel { + id: Int! + nodesetId: Long! + name: String + value: String +} + +type Nodeset { + nodesetXml: String + identifier: UnsignedInt! + namespaceUri: URL + version: String + publicationDate: DateTime! + lastModifiedDate: DateTime! + validationStatus: String + requiredModels: [CloudLibRequiredModelInfo] +} + +# The `TimeSpan` scalar represents an ISO-8601 compliant duration type. +scalar TimeSpan + +type CloudLibRequiredModelInfo { + namespaceUri: String + publicationDate: DateTime + version: String + availableModel: Nodeset +} diff --git a/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.dependingtestnodeset001.V1_2.NodeSet2.xml.0.json b/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.dependingtestnodeset001.V1_2.NodeSet2.xml.0.json new file mode 100644 index 00000000..ff0381f4 --- /dev/null +++ b/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.dependingtestnodeset001.V1_2.NodeSet2.xml.0.json @@ -0,0 +1,54 @@ +{ + "title": "CloudLib Depending Test Nodeset 001", + "license": 2, + "copyrightText": "Copyright OPC Foundation", + "contributor": { + "name": "OPC Foundation", + "description": "Test Description", + "logoUrl": "http://testlogourl.com", + "contactEmail": "test@b.c", + "website": "http://testwebsiteurl.com" + }, + "description": "CloudLib Test description", + "category": { + "name": "test name", + "description": "test category description", + "iconUrl": "http://testcategoryurl.com" + }, + "nodeset": { + "nodesetXml": "\n\n\n \n http://cloudlibtests/dependingtestnodeset001/\n http://cloudlibtests/testnodeset001/\n http://opcfoundation.org/UA/DI/\n \n \n \n \n \n \n \n \n \n i=1\n i=2\n i=3\n i=4\n i=5\n i=6\n i=7\n i=8\n i=9\n i=10\n i=11\n i=13\n i=12\n i=15\n i=14\n i=16\n i=17\n i=18\n i=20\n i=21\n i=19\n i=22\n i=26\n i=27\n i=28\n i=47\n i=46\n i=35\n i=36\n i=48\n i=45\n i=40\n i=37\n i=38\n i=39\n \n\n \n TestDeviceType2\n \n ns=2;g=4f8b000c-da16-4705-9c74-9e8d54731f7d\n \n \n\n", + "identifier": 0, + "namespaceUri": "http://cloudlibtests/dependingtestnodeset001/", + "version": "1.02.0", + "validationStatus": null, + "publicationDate": "2023-07-06T00:00:00Z", + "lastModifiedDate": "2022-11-23T00:00:00Z", + "RequiredModels": null + }, + "documentationUrl": "http://testdocumentationurl.com", + "iconUrl": "http://testiconurl.com", + "licenseUrl": "http://testlicenseurl.com", + "keywords": [ + "testkw1", + "testkw2" + ], + "purchasingInformationUrl": "http://purchasinginformationurl.com", + "releaseNotesUrl": "http://releasenotesurl.com", + "testSpecificationUrl": "http://testspecificationurl.com", + "supportedLocales": [ + "en", + "de" + ], + "numberOfDownloads": 12345678, + "validationStatus": null, + "additionalProperties": [ + { + "Name": "TestProp1", + "Value": "TestProp1Value" + }, + { + "Name": "TestProp2", + "Value": "TestProp2Value" + } + ] +} \ No newline at end of file diff --git a/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.testnodeset001.V1_2.NodeSet2.xml.0.json b/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.testnodeset001.V1_2.NodeSet2.xml.0.json index 2a26ea56..2a4b1931 100644 --- a/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.testnodeset001.V1_2.NodeSet2.xml.0.json +++ b/Tests/CloudLibClientTests/OtherTestNamespaces/cloudlibtests.testnodeset001.V1_2.NodeSet2.xml.0.json @@ -16,7 +16,7 @@ "iconUrl": "http://testcategoryurl.com" }, "nodeset": { - "nodesetXml": "\n\n\n \n http://cloudlibtests/testnodeset001/\n http://opcfoundation.org/UA/DI/\n \n \n \n \n \n \n \n \n i=1\n i=2\n i=3\n i=4\n i=5\n i=6\n i=7\n i=8\n i=9\n i=10\n i=11\n i=13\n i=12\n i=15\n i=14\n i=16\n i=17\n i=18\n i=20\n i=21\n i=19\n i=22\n i=26\n i=27\n i=28\n i=47\n i=46\n i=35\n i=36\n i=48\n i=45\n i=40\n i=37\n i=38\n i=39\n \n\n\n", + "nodesetXml": "\n\n\n \n http://cloudlibtests/testnodeset001/\n http://opcfoundation.org/UA/DI/\n \n \n \n \n \n \n \n \n i=1\n i=2\n i=3\n i=4\n i=5\n i=6\n i=7\n i=8\n i=9\n i=10\n i=11\n i=13\n i=12\n i=15\n i=14\n i=16\n i=17\n i=18\n i=20\n i=21\n i=19\n i=22\n i=26\n i=27\n i=28\n i=47\n i=46\n i=35\n i=36\n i=48\n i=45\n i=40\n i=37\n i=38\n i=39\n \n\n \n TestDeviceType\n \n ns=2;i=1002\n \n \n\n", "identifier": 0, "namespaceUri": "http://cloudlibtests/testnodeset001/", "version": "1.02.0", diff --git a/Tests/CloudLibClientTests/QueriesAndDownload.cs b/Tests/CloudLibClientTests/QueriesAndDownload.cs index ed1a9e3a..e6f2f4b0 100644 --- a/Tests/CloudLibClientTests/QueriesAndDownload.cs +++ b/Tests/CloudLibClientTests/QueriesAndDownload.cs @@ -205,6 +205,7 @@ public async Task DownloadNodesetAsync() const string strTestNamespaceTitle = "CloudLib Test Nodeset 001"; const string strTestNamespaceFilename = "cloudlibtests.testnodeset001.NodeSet2.xml.0.json"; const string strTestNamespaceUpdateFilename = "cloudlibtests.testnodeset001.V1_2.NodeSet2.xml.0.json"; + const string strTestDependingNamespaceFilename = "cloudlibtests.dependingtestnodeset001.V1_2.NodeSet2.xml.0.json"; private static UANameSpace GetUploadedTestNamespace() { var uploadedJson = File.ReadAllText(Path.Combine("TestNamespaces", strTestNamespaceFilename)); @@ -402,19 +403,21 @@ public async Task GetConvertedMetadataAsync(bool forceRest) [InlineData("TestNamespaces", strTestNamespaceFilename, true)] [InlineData("TestNamespaces", "opcfoundation.org.UA.DI.NodeSet2.xml.2844662655.json", true)] [InlineData("TestNamespaces", "opcfoundation.org.UA.2022-11-01.NodeSet2.xml.3338611482.json", true)] - public async Task UpdateNodeSet(string path, string fileName, bool uploadConflictExpected = false) + [InlineData("OtherTestNamespaces", strTestDependingNamespaceFilename, false, strTestNamespaceUpdateFilename)] // Depends on test namespace 1.02 + public async Task UpdateNodeSet(string path, string fileName, bool uploadConflictExpected = false, string dependentNodeSet = null) { var client = _factory.CreateCloudLibClient(); var expectedNodeSetCount = (await client.GetNodeSetsAsync().ConfigureAwait(false)).TotalCount; + string uploadedIdentifier = null; var uploadJson = File.ReadAllText(Path.Combine(path, fileName)); var addressSpace = JsonConvert.DeserializeObject(uploadJson); var response = await client.UploadNodeSetAsync(addressSpace).ConfigureAwait(false); if (response.Status == HttpStatusCode.OK) { output.WriteLine($"Uploaded {addressSpace?.Nodeset.NamespaceUri}, {addressSpace?.Nodeset.Identifier}"); - var uploadedIdentifier = response.Message; + uploadedIdentifier = response.Message; var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null).ConfigureAwait(false); Assert.NotNull(approvalResult); Assert.Equal("APPROVED", approvalResult.ApprovalStatus); @@ -433,6 +436,7 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic else { Assert.Equal(HttpStatusCode.OK, response.Status); + uploadedIdentifier = response.Message; } } // Upload again should cause conflict @@ -443,13 +447,48 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic // Wait for indexing bool notIndexed; + bool dependencyUploaded = false; + string requiredIdentifier = null; do { nodeSetInfo = await client.GetNodeSetsAsync(modelUri: addressSpace.Nodeset.NamespaceUri.OriginalString, publicationDate: addressSpace.Nodeset.PublicationDate).ConfigureAwait(false); - notIndexed = nodeSetInfo.TotalCount == 1 && nodeSetInfo.Edges[0].Node.ValidationStatus != "INDEXED"; - if (notIndexed) + Assert.NotEmpty(nodeSetInfo.Nodes); + var uploadedNode = nodeSetInfo.Nodes.Where(n => n.NamespaceUri.OriginalString == addressSpace.Nodeset.NamespaceUri.OriginalString && n.PublicationDate == addressSpace.Nodeset.PublicationDate).FirstOrDefault(); + Assert.Contains(uploadedNode, nodeSetInfo.Nodes); + if (dependentNodeSet != null && !dependencyUploaded) { - await Task.Delay(5000); + if (uploadedNode.ValidationStatus == "PARSED") + { + await Task.Delay(5000); + notIndexed = true; + } + else + { + // Verify that the dependency is missing + Assert.Equal("ERROR", uploadedNode.ValidationStatus); + + var requiredUploadJson = File.ReadAllText(Path.Combine(path, dependentNodeSet)); + var requiredAddressSpace = JsonConvert.DeserializeObject(requiredUploadJson); + response = await client.UploadNodeSetAsync(requiredAddressSpace).ConfigureAwait(false); + Assert.Equal(HttpStatusCode.OK, response.Status); + requiredIdentifier = response.Message; + + var approvalResult = await client.UpdateApprovalStatusAsync(requiredIdentifier, "APPROVED", null, null); + Assert.NotNull(approvalResult); + Assert.Equal("APPROVED", approvalResult.ApprovalStatus); + + dependencyUploaded = true; + notIndexed = true; + } + } + else + { + //Assert.NotEqual("ERROR", uploadedNode.ValidationStatus); + notIndexed = uploadedNode.ValidationStatus != "INDEXED"; + if (notIndexed) + { + await Task.Delay(5000); + } } } while (notIndexed); await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount).ConfigureAwait(false); @@ -458,7 +497,7 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic response = await client.UploadNodeSetAsync(addressSpace, true).ConfigureAwait(false); Assert.Equal(HttpStatusCode.OK, response.Status); { - var uploadedIdentifier = response.Message; + uploadedIdentifier = response.Message; var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null).ConfigureAwait(false); Assert.NotNull(approvalResult); Assert.Equal("APPROVED", approvalResult.ApprovalStatus); @@ -473,6 +512,23 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic await Task.Delay(5000); } } while (notIndexed); + await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount); + if (!uploadConflictExpected && uploadedIdentifier != null) + { + var cancelResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "CANCELED", "Test cleanup", null); + Assert.NotNull(cancelResult); + Assert.Equal("CANCELED", cancelResult.ApprovalStatus); + } + if (requiredIdentifier != null) + { + var cancelResult = await client.UpdateApprovalStatusAsync(requiredIdentifier, "CANCELED", "Test cleanup", null); + Assert.NotNull(cancelResult); + Assert.Equal("CANCELED", cancelResult.ApprovalStatus); + } + + //Trigger reindexing + addressSpace.Nodeset.NodesetXml = null; + await client.UploadNodeSetAsync(addressSpace, false); await UploadAndIndex.WaitForIndexAsync(_factory.CreateAuthorizedClient(), expectedNodeSetCount).ConfigureAwait(false); } diff --git a/Tests/CloudLibClientTests/TestNamespaces/cloudlibtests.testnodeset001.NodeSet2.xml.0.json b/Tests/CloudLibClientTests/TestNamespaces/cloudlibtests.testnodeset001.NodeSet2.xml.0.json index 42707361..7c908995 100644 --- a/Tests/CloudLibClientTests/TestNamespaces/cloudlibtests.testnodeset001.NodeSet2.xml.0.json +++ b/Tests/CloudLibClientTests/TestNamespaces/cloudlibtests.testnodeset001.NodeSet2.xml.0.json @@ -16,7 +16,7 @@ "iconUrl": "http://testcategoryurl.com" }, "nodeset": { - "nodesetXml": "\n\n\n \n http://cloudlibtests/testnodeset001/\n http://opcfoundation.org/UA/DI/\n \n \n \n \n \n \n \n \n i=1\n i=2\n i=3\n i=4\n i=5\n i=6\n i=7\n i=8\n i=9\n i=10\n i=11\n i=13\n i=12\n i=15\n i=14\n i=16\n i=17\n i=18\n i=20\n i=21\n i=19\n i=22\n i=26\n i=27\n i=28\n i=47\n i=46\n i=35\n i=36\n i=48\n i=45\n i=40\n i=37\n i=38\n i=39\n \n \n", + "nodesetXml": "\n\n\n \n http://cloudlibtests/testnodeset001/\n http://opcfoundation.org/UA/DI/\n \n \n \n \n \n \n \n \n i=1\n i=2\n i=3\n i=4\n i=5\n i=6\n i=7\n i=8\n i=9\n i=10\n i=11\n i=13\n i=12\n i=15\n i=14\n i=16\n i=17\n i=18\n i=20\n i=21\n i=19\n i=22\n i=26\n i=27\n i=28\n i=47\n i=46\n i=35\n i=36\n i=48\n i=45\n i=40\n i=37\n i=38\n i=39\n \n \n TestDeviceType\n \n ns=2;i=1002\n \n \n \n", "identifier": 0, "namespaceUri": "http://cloudlibtests/testnodeset001/", "version": "1.01.2", diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs index a7c5eb2d..da52717a 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs @@ -128,8 +128,7 @@ public async Task OnGetCallbackAsync(string returnUrl = null, str ProviderDisplayName = info.ProviderDisplayName; if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email)) { - Input = new InputModel - { + Input = new InputModel { Email = info.Principal.FindFirstValue(ClaimTypes.Email) }; } diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml index 8d74e6ec..98a71bd9 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml @@ -47,36 +47,25 @@
+@if ((Model.ExternalLogins?.Count ?? 0) > 0) +{

Use another account to log in:


- @{ - if ((Model.ExternalLogins?.Count ?? 0) == 0) - { -
-

- There are no external authentication services configured. See this article - about setting up this ASP.NET application to support logging in via external services. -

-
- } - else - { -
-
-

- @foreach (var provider in Model.ExternalLogins!) - { - - } -

-
-
- } - } +
+
+

+ @foreach (var provider in Model.ExternalLogins!) + { + + } +

+
+
+}
@section Scripts { diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs index 48f100a7..63481e2a 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs @@ -77,8 +77,7 @@ private async Task LoadAsync(IdentityUser user) var email = await _userManager.GetEmailAsync(user).ConfigureAwait(false); Email = email; - Input = new InputModel - { + Input = new InputModel { NewEmail = email, }; diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs index 074603f3..05b250fb 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.cshtml.cs @@ -65,8 +65,7 @@ private async Task LoadAsync(IdentityUser user) Username = userName; - Input = new InputModel - { + Input = new InputModel { PhoneNumber = phoneNumber }; } diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs index 8314c320..6cb85127 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -77,8 +77,7 @@ public IActionResult OnGet(string code = null) } else { - Input = new InputModel - { + Input = new InputModel { Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)) }; return Page(); diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs index 6fa32507..39ae6d58 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPasswordConfirmation.cshtml.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. #nullable disable diff --git a/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs b/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs index a134a5d9..f3342d69 100644 --- a/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs +++ b/UACloudLibraryServer/CloudLibDataProviderLegacyMetadata.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using Opc.Ua.Cloud.Library.Controllers; using Opc.Ua.Cloud.Library.DbContextModels; using Opc.Ua.Cloud.Library.Interfaces; using Opc.Ua.Cloud.Library.Models; diff --git a/UACloudLibraryServer/Components/Pages/Explorer.razor b/UACloudLibraryServer/Components/Pages/Explorer.razor index 2249d504..a82e47f2 100644 --- a/UACloudLibraryServer/Components/Pages/Explorer.razor +++ b/UACloudLibraryServer/Components/Pages/Explorer.razor @@ -189,7 +189,7 @@ { SearchKeywords = keywords; CurrentPage = 1; - fetchData(); + _ = fetchData(); } } private void OnPageSizeChanged(Microsoft.AspNetCore.Components.ChangeEventArgs patharg) @@ -198,7 +198,7 @@ { PAGE_SIZE = pageSize; CurrentPage = 1; - fetchData(); + _ = fetchData(); } } private string ModalClass = ""; @@ -226,10 +226,9 @@ private string[] SearchKeywords { get; set; } = new[] { "*" }; - protected override void OnInitialized() + protected override async Task OnInitializedAsync() { - - fetchData(); + await fetchData(); } private async Task DownloadFileFromURL(NamespaceMetaDataModel item) @@ -265,7 +264,7 @@ if (!CurrentPage.Equals(newPage)) { CurrentPage = newPage; - fetchData(); + _ = fetchData(); } } @@ -274,7 +273,7 @@ changePageAbsolute(this.CurrentPage + increment); } - private async void fetchData() + private async Task fetchData() { Loading = true; var query = HttpUtility.ParseQueryString(string.Empty); diff --git a/UACloudLibraryServer/Controllers/AccessController.cs b/UACloudLibraryServer/Controllers/AccessController.cs new file mode 100644 index 00000000..39c96abe --- /dev/null +++ b/UACloudLibraryServer/Controllers/AccessController.cs @@ -0,0 +1,97 @@ +/* ======================================================================== + * Copyright (c) 2005-2021 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +namespace Opc.Ua.Cloud.Library.Controllers +{ + using System.ComponentModel.DataAnnotations; + using System.Net; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authorization; + using Microsoft.AspNetCore.Identity; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Logging; + using Swashbuckle.AspNetCore.Annotations; + + [Authorize(AuthenticationSchemes = "BasicAuthentication")] + [ApiController] + public class AccessController : ControllerBase + { + private readonly IDatabase _database; + private readonly ILogger _logger; + + public AccessController(IDatabase database, ILoggerFactory logger) + { + _database = database; + _logger = logger.CreateLogger("ApprovalController"); + } + + [HttpPut] + [Route("/access/roles/{roleName}")] + [Authorize(Policy = "UserAdministrationPolicy")] + [SwaggerResponse(statusCode: 200, type: typeof(string), description: "A status message indicating the successful approval.")] + [SwaggerResponse(statusCode: 404, type: typeof(string), description: "The provided nodeset was not found.")] + [SwaggerResponse(statusCode: 500, type: typeof(string), description: "The provided information model could not be stored or updated.")] + public async Task AddRoleAsync( + [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string roleName, + [FromServices] RoleManager roleManager + ) + { + var result = await roleManager.CreateAsync(new IdentityRole { Name = roleName }).ConfigureAwait(false); + if (!result.Succeeded) + { + return this.BadRequest(result); + } + return new ObjectResult("Role added successfully") { StatusCode = (int)HttpStatusCode.OK }; + } + [HttpPut] + [Route("/access/userRoles/{userId}/{roleName}")] + [Authorize(Policy = "UserAdministrationPolicy")] + [SwaggerResponse(statusCode: 200, type: typeof(string), description: "A status message indicating the successful approval.")] + [SwaggerResponse(statusCode: 404, type: typeof(string), description: "The provided nodeset was not found.")] + [SwaggerResponse(statusCode: 500, type: typeof(string), description: "The provided information model could not be stored or updated.")] + public async Task AddRoleToUserAsync( + [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string userId, + [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string roleName, + [FromServices] UserManager userManager + ) + { + var user = await userManager.FindByIdAsync(userId).ConfigureAwait(false); + if (user == null) + { + return NotFound(); + } + var result = await userManager.AddToRoleAsync(user, roleName).ConfigureAwait(false); + if (!result.Succeeded) + { + return this.BadRequest(result); + } + return new ObjectResult("User role added successfully") { StatusCode = (int)HttpStatusCode.OK }; + } + } +} diff --git a/UACloudLibraryServer/Controllers/ApprovalController.cs b/UACloudLibraryServer/Controllers/ApprovalController.cs index 07fe3a5d..46ec770c 100644 --- a/UACloudLibraryServer/Controllers/ApprovalController.cs +++ b/UACloudLibraryServer/Controllers/ApprovalController.cs @@ -27,7 +27,7 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ -namespace Opc.Ua.Cloud.Library +namespace Opc.Ua.Cloud.Library.Controllers { using System.ComponentModel.DataAnnotations; using System.Net; @@ -70,48 +70,5 @@ public async Task ApproveNameSpaceAsync( _logger.LogError($"Approval failed: {identifier} not found."); return NotFound(); } - - [HttpPut] - [Route("/access/roles/{roleName}")] - [Authorize(Policy = "UserAdministrationPolicy")] - [SwaggerResponse(statusCode: 200, type: typeof(string), description: "A status message indicating the successful approval.")] - [SwaggerResponse(statusCode: 404, type: typeof(string), description: "The provided nodeset was not found.")] - [SwaggerResponse(statusCode: 500, type: typeof(string), description: "The provided information model could not be stored or updated.")] - public async Task AddRoleAsync( - [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string roleName, - [FromServices] RoleManager roleManager - ) - { - var result = await roleManager.CreateAsync(new IdentityRole { Name = roleName }).ConfigureAwait(false); - if (!result.Succeeded) - { - return this.BadRequest(result); - } - return new ObjectResult("Role added successfully") { StatusCode = (int)HttpStatusCode.OK }; - } - [HttpPut] - [Route("/access/userRoles/{userId}/{roleName}")] - [Authorize(Policy = "UserAdministrationPolicy")] - [SwaggerResponse(statusCode: 200, type: typeof(string), description: "A status message indicating the successful approval.")] - [SwaggerResponse(statusCode: 404, type: typeof(string), description: "The provided nodeset was not found.")] - [SwaggerResponse(statusCode: 500, type: typeof(string), description: "The provided information model could not be stored or updated.")] - public async Task AddRoleToUserAsync( - [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string userId, - [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string roleName, - [FromServices] UserManager userManager - ) - { - var user = await userManager.FindByIdAsync(userId).ConfigureAwait(false); - if (user == null) - { - return NotFound(); - } - var result = await userManager.AddToRoleAsync(user, roleName).ConfigureAwait(false); - if (!result.Succeeded) - { - return this.BadRequest(result); - } - return new ObjectResult("User role added successfully") { StatusCode = (int)HttpStatusCode.OK }; - } } } diff --git a/UACloudLibraryServer/Controllers/InfoModelController.cs b/UACloudLibraryServer/Controllers/InfoModelController.cs index 4eb84e27..f6bfeaa6 100644 --- a/UACloudLibraryServer/Controllers/InfoModelController.cs +++ b/UACloudLibraryServer/Controllers/InfoModelController.cs @@ -27,19 +27,21 @@ * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ -namespace Opc.Ua.Cloud.Library +namespace Opc.Ua.Cloud.Library.Controllers { using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; using System.IO; + using System.Linq; using System.Net; using System.Text; using System.Threading.Tasks; using Extensions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; + using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Opc.Ua.Cloud.Library.Interfaces; using Opc.Ua.Cloud.Library.Models; @@ -143,30 +145,44 @@ public async Task DownloadNamespaceAsync( return new ObjectResult(uaNamespace) { StatusCode = (int)HttpStatusCode.OK }; } -#if DEBUG + [Authorize(Policy = "DeletePolicy")] [HttpGet] [Route("/infomodel/delete/{identifier}")] [SwaggerResponse(statusCode: 200, type: typeof(UANameSpace), description: "The OPC UA Information model and its metadata.")] [SwaggerResponse(statusCode: 400, type: typeof(string), description: "The identifier provided could not be parsed.")] [SwaggerResponse(statusCode: 404, type: typeof(string), description: "The identifier provided could not be found.")] public async Task DeleteNamespaceAsync( - [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string identifier) + [FromRoute][Required][SwaggerParameter("OPC UA Information model identifier.")] string identifier, + [FromQuery][SwaggerParameter("Delete even if other nodesets depend on this nodeset.")] bool forceDelete = false) { + uint nodeSetID = 0; + if (!uint.TryParse(identifier, out nodeSetID)) + { + return new ObjectResult("Could not parse identifier") { StatusCode = (int)HttpStatusCode.BadRequest }; + } + string nodesetXml = await _storage.DownloadFileAsync(identifier).ConfigureAwait(false); if (string.IsNullOrEmpty(nodesetXml)) { return new ObjectResult("Failed to find nodeset") { StatusCode = (int)HttpStatusCode.NotFound }; } - uint nodeSetID = 0; - if (!uint.TryParse(identifier, out nodeSetID)) + var nodeSetMeta = await (_database.GetNodeSets(identifier).FirstOrDefaultAsync()); + if (nodeSetMeta != null) { - return new ObjectResult("Could not parse identifier") { StatusCode = (int)HttpStatusCode.BadRequest }; + var dependentNodeSets = await _database.GetNodeSets().Where(n => n.RequiredModels.Any(rm => rm.AvailableModel == nodeSetMeta)).ToListAsync(); + if (dependentNodeSets.Any()) + { + var message = $"NodeSet {nodeSetMeta} is used by the following nodesets: {string.Join(",", dependentNodeSets.Select(n => n.ToString()))}"; + if (!forceDelete) + { + return new ObjectResult(message) { StatusCode = (int)HttpStatusCode.Conflict }; + } + _logger.LogWarning($"{message}. Deleting anyway because forceDelete was specified. Nodeset Index may be incomplete."); + } } - var uaNamespace = await _database.RetrieveAllMetadataAsync(nodeSetID).ConfigureAwait(false); uaNamespace.Nodeset.NodesetXml = nodesetXml; - await _indexer.DeleteNodeSetIndex(identifier).ConfigureAwait(false); await _database.DeleteAllRecordsForNodesetAsync(nodeSetID).ConfigureAwait(false); @@ -174,7 +190,6 @@ public async Task DeleteNamespaceAsync( return new ObjectResult(uaNamespace) { StatusCode = (int)HttpStatusCode.OK }; } -#endif [HttpPut] [Route("/infomodel/upload")] @@ -188,6 +203,10 @@ public async Task UploadNamespaceAsync( { try { + if (uaNamespace?.Nodeset?.NodesetXml == null) + { + return new ObjectResult($"No nodeset XML was specified") { StatusCode = (int)HttpStatusCode.BadRequest }; + } UANodeSet nodeSet = null; try { @@ -228,12 +247,12 @@ public async Task UploadNamespaceAsync( { return new ObjectResult($"Nodeset exists but existing nodeset had no model entry.") { StatusCode = (int)HttpStatusCode.Conflict }; } - if (!firstModel.PublicationDateSpecified || firstModel.PublicationDate == nodeSet.Models[0].PublicationDate) + if ((!firstModel.PublicationDateSpecified && !nodeSet.Models[0].PublicationDateSpecified) || firstModel.PublicationDate == nodeSet.Models[0].PublicationDate) { if (!overwrite) { // nodeset already exists - return new ObjectResult("Nodeset already exists. Use overwrite flag to overwrite this existing entry in the Library.") { StatusCode = (int)HttpStatusCode.Conflict }; + return new ObjectResult("Nodeset already exists. Use overwrite flag to overwrite this existing legacy entry in the Library.") { StatusCode = (int)HttpStatusCode.Conflict }; } } else diff --git a/UACloudLibraryServer/GraphQL/ApprovalModel.cs b/UACloudLibraryServer/GraphQL/ApprovalModel.cs index d240d825..3ab3c86c 100644 --- a/UACloudLibraryServer/GraphQL/ApprovalModel.cs +++ b/UACloudLibraryServer/GraphQL/ApprovalModel.cs @@ -29,12 +29,9 @@ namespace Opc.Ua.Cloud.Library { - using System; using System.Collections.Generic; - using System.Globalization; using System.Linq; using System.Threading.Tasks; - using CESMII.OpcUa.NodeSetModel; using HotChocolate; using HotChocolate.Authorization; using HotChocolate.Data; diff --git a/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs b/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs index cb0a4f71..e6c7d19b 100644 --- a/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs +++ b/UACloudLibraryServer/NodeSetIndex/NodeSetModelIndexer.cs @@ -37,6 +37,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Opc.Ua.Cloud.Library.Controllers; using Opc.Ua.Cloud.Library.Interfaces; using Opc.Ua.Export; diff --git a/UACloudLibraryServer/NodeSetIndex/UANodeSetIFileStorage.cs b/UACloudLibraryServer/NodeSetIndex/UANodeSetIFileStorage.cs index a2feaa82..5aaaad91 100644 --- a/UACloudLibraryServer/NodeSetIndex/UANodeSetIFileStorage.cs +++ b/UACloudLibraryServer/NodeSetIndex/UANodeSetIFileStorage.cs @@ -31,6 +31,7 @@ using System.Linq; using CESMII.OpcUa.NodeSetImporter; using CESMII.OpcUa.NodeSetModel.EF; +using Opc.Ua.Cloud.Library.Controllers; using Opc.Ua.Cloud.Library.Interfaces; namespace Opc.Ua.Cloud.Library diff --git a/UACloudLibraryServer/Startup.cs b/UACloudLibraryServer/Startup.cs index f45e11a6..93e59406 100644 --- a/UACloudLibraryServer/Startup.cs +++ b/UACloudLibraryServer/Startup.cs @@ -42,7 +42,6 @@ namespace Opc.Ua.Cloud.Library using HotChocolate.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.OAuth; - using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Hosting; @@ -55,8 +54,14 @@ namespace Opc.Ua.Cloud.Library using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +#if AZURE_AD + using Microsoft.AspNetCore.Authentication.OpenIdConnect; + using Microsoft.IdentityModel.Logging; + using Microsoft.Identity.Web; +#endif using Microsoft.OpenApi.Models; using Opc.Ua.Cloud.Library.Interfaces; + using Microsoft.AspNetCore.Authorization; public class Startup { @@ -81,8 +86,8 @@ public void ConfigureServices(IServiceCollection services) services.AddDbContext(ServiceLifetime.Transient); services.AddDefaultIdentity(options => - //require confirmation mail if email sender API Key is set - options.SignIn.RequireConfirmedAccount = !string.IsNullOrEmpty(Configuration["EmailSenderAPIKey"]) + //require confirmation mail if email sender API Key is set + options.SignIn.RequireConfirmedAccount = !string.IsNullOrEmpty(Configuration["EmailSenderAPIKey"]) ) .AddRoles() .AddEntityFrameworkStores(); @@ -104,53 +109,74 @@ public void ConfigureServices(IServiceCollection services) services.AddAuthentication() .AddScheme("BasicAuthentication", null) - .AddOAuth("OAuth", "OPC Foundation", options => - { - options.AuthorizationEndpoint = "https://opcfoundation.org/oauth/authorize/"; - options.TokenEndpoint = "https://opcfoundation.org/oauth/token/"; - options.UserInformationEndpoint = "https://opcfoundation.org/oauth/me"; + ; - options.AccessDeniedPath = new PathString("/Account/AccessDenied"); - options.CallbackPath = new PathString("/Account/ExternalLogin"); + if (Configuration["OAuth2ClientId"] != null) + { + services.AddAuthentication() + .AddOAuth("OAuth", "OPC Foundation", options => { + options.AuthorizationEndpoint = "https://opcfoundation.org/oauth/authorize/"; + options.TokenEndpoint = "https://opcfoundation.org/oauth/token/"; + options.UserInformationEndpoint = "https://opcfoundation.org/oauth/me"; - options.ClientId = Configuration["OAuth2ClientId"]; - options.ClientSecret = Configuration["OAuth2ClientSecret"]; + options.AccessDeniedPath = new PathString("/Account/AccessDenied"); + options.CallbackPath = new PathString("/Account/ExternalLogin"); - options.SaveTokens = true; + options.ClientId = Configuration["OAuth2ClientId"]; + options.ClientSecret = Configuration["OAuth2ClientSecret"]; - options.CorrelationCookie.SameSite = SameSiteMode.Strict; - options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; + options.SaveTokens = true; - options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "ID"); - options.ClaimActions.MapJsonKey(ClaimTypes.Name, "display_name"); - options.ClaimActions.MapJsonKey(ClaimTypes.Email, "user_email"); + options.CorrelationCookie.SameSite = SameSiteMode.Strict; + options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always; - options.Events = new OAuthEvents { - OnCreatingTicket = async context => - { - List tokens = (List)context.Properties.GetTokens(); + options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "ID"); + options.ClaimActions.MapJsonKey(ClaimTypes.Name, "display_name"); + options.ClaimActions.MapJsonKey(ClaimTypes.Email, "user_email"); - tokens.Add(new AuthenticationToken() { - Name = "TicketCreated", - Value = DateTime.UtcNow.ToString(DateTimeFormatInfo.InvariantInfo) - }); + options.Events = new OAuthEvents { + OnCreatingTicket = async context => { + List tokens = (List)context.Properties.GetTokens(); - context.Properties.StoreTokens(tokens); + tokens.Add(new AuthenticationToken() { + Name = "TicketCreated", + Value = DateTime.UtcNow.ToString(DateTimeFormatInfo.InvariantInfo) + }); - HttpResponseMessage response = await context.Backchannel.GetAsync($"{context.Options.UserInformationEndpoint}?access_token={context.AccessToken}").ConfigureAwait(false); - response.EnsureSuccessStatusCode(); + context.Properties.StoreTokens(tokens); - string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); - JsonElement user = JsonDocument.Parse(json).RootElement; + HttpResponseMessage response = await context.Backchannel.GetAsync($"{context.Options.UserInformationEndpoint}?access_token={context.AccessToken}").ConfigureAwait(false); + response.EnsureSuccessStatusCode(); - context.RunClaimActions(user); - } - }; - }); + string json = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + JsonElement user = JsonDocument.Parse(json).RootElement; + + context.RunClaimActions(user); + } + }; + }); + } + +#if AZURE_AD + if (Configuration.GetSection("AzureAd")?["ClientId"] != null) + { + services.AddAuthentication() + .AddMicrosoftIdentityWebApp(Configuration, + configSectionName: "AzureAd", + openIdConnectScheme: "AzureAd", + displayName: Configuration["AADDisplayName"] ?? "Microsoft Account") + ; + } +#if DEBUG + IdentityModelEventSource.ShowPII = true; +#endif + +#endif services.AddAuthorization(options => { options.AddPolicy("ApprovalPolicy", policy => policy.RequireRole("Administrator")); options.AddPolicy("UserAdministrationPolicy", policy => policy.RequireRole("Administrator")); + options.AddPolicy("DeletePolicy", policy => policy.RequireRole("Administrator")); }); services.AddSwaggerGen(options => { @@ -165,6 +191,11 @@ public void ConfigureServices(IServiceCollection services) } }); + options.AddSecurityDefinition("basicAuth", new OpenApiSecurityScheme { + Type = SecuritySchemeType.Http, + Scheme = "basic" + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement { { @@ -173,7 +204,7 @@ public void ConfigureServices(IServiceCollection services) Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, - Id = "basic" + Id = "basicAuth" } }, Array.Empty() @@ -220,13 +251,25 @@ public void ConfigureServices(IServiceCollection services) services.AddHttpContextAccessor(); - services.AddGraphQLServer() - .AddAuthorization() - .SetPagingOptions(new HotChocolate.Types.Pagination.PagingOptions { + + HotChocolate.Types.Pagination.PagingOptions paginationConfig; + var section = Configuration.GetSection("GraphQLPagination"); + if (section.Exists()) + { + paginationConfig = section.Get(); + } + else + { + paginationConfig = new HotChocolate.Types.Pagination.PagingOptions { IncludeTotalCount = true, DefaultPageSize = 100, MaxPageSize = 100, - }) + }; + } + + services.AddGraphQLServer() + .AddAuthorization() + .SetPagingOptions(paginationConfig) .AddFiltering(fd => { fd.AddDefaults().BindRuntimeType(); fd.AddDefaults().BindRuntimeType(); @@ -252,8 +295,17 @@ public void ConfigureServices(IServiceCollection services) }); services.AddServerSideBlazor(); +#if AZURE_AD + // Required to make Azure AD login work as ASP.Net External Identity: Change the SignInScheme to External after ALL other configuration have run. + services + .AddOptions() + .PostConfigureAll(o => { + o.SignInScheme = IdentityConstants.ExternalScheme; + }); +#endif } + // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IWebHostEnvironment env, AppDbContext appDbContext) { @@ -287,7 +339,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, AppDbCon }); app.UseGraphQLGraphiQL("/graphiql", new GraphQL.Server.Ui.GraphiQL.GraphiQLOptions { ExplorerExtensionEnabled = true, - + RequestCredentials = GraphQL.Server.Ui.GraphiQL.RequestCredentials.Include, }); app.UseEndpoints(endpoints => { @@ -298,7 +350,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, AppDbCon endpoints.MapRazorPages(); endpoints.MapBlazorHub(); endpoints.MapGraphQL() - .RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = "BasicAuthentication" }) + .RequireAuthorization(new AuthorizeAttribute() { AuthenticationSchemes = "BasicAuthentication" }) .WithOptions(new GraphQLServerOptions { EnableGetRequests = true, Tool = { Enable = false }, diff --git a/UACloudLibraryServer/UA-CloudLibrary.csproj b/UACloudLibraryServer/UA-CloudLibrary.csproj index 2515c8a0..caa4c06c 100644 --- a/UACloudLibraryServer/UA-CloudLibrary.csproj +++ b/UACloudLibraryServer/UA-CloudLibrary.csproj @@ -7,7 +7,7 @@ Linux ./.. ..\docker-compose.dcproj - $(DEFINECONSTANTS);NOLEGACY + $(DEFINECONSTANTS);NOLEGACY;AZURE_AD @@ -43,11 +43,11 @@ - - - - - + + + + + @@ -80,6 +80,9 @@ + + + From 6d5fd7cd2fbc5ac1158b20f2d1dd96b6fca7b103 Mon Sep 17 00:00:00 2001 From: Markus Horstmann Date: Mon, 25 Sep 2023 11:25:07 -0700 Subject: [PATCH 6/8] Fix merge --- UACloudLibraryServer/UA-CloudLibrary.csproj | 162 ++++++++++---------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/UACloudLibraryServer/UA-CloudLibrary.csproj b/UACloudLibraryServer/UA-CloudLibrary.csproj index b11aa1a6..38f19db2 100644 --- a/UACloudLibraryServer/UA-CloudLibrary.csproj +++ b/UACloudLibraryServer/UA-CloudLibrary.csproj @@ -1,89 +1,89 @@  - - net6.0 - Opc.Ua.Cloud.Library - 78fb557e-0608-425b-a56c-dfebc15e2b58 - Linux - ./.. - ..\docker-compose.dcproj - $(DEFINECONSTANTS);NOLEGACY;AZURE_AD - + + net6.0 + Opc.Ua.Cloud.Library + ee5a630a-263e-4334-b590-b77013c2af56 + Linux + ./.. + ..\docker-compose.dcproj + $(DEFINECONSTANTS);NOLEGACY;AZURE_AD + - + - - - - - - - - - + + + + + + + + + - - - - + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + From 76a86b670fd32b6a915477af13f9827822f29075 Mon Sep 17 00:00:00 2001 From: MarkusHorstmann Date: Thu, 5 Oct 2023 14:48:02 -0700 Subject: [PATCH 7/8] Home page: add PD/Marketplace links, client SDK. OPC logos. (#51) * Home page: add PD/Marketplace links, client SDK. OPC logos. * Readme update for Microsoft Identity login --- README.md | 32 +++ UACloudLibraryServer/Views/Home/Index.cshtml | 231 +++++++++++------- .../wwwroot/images/OPC UA Logo square.png | Bin 0 -> 22920 bytes 3 files changed, 181 insertions(+), 82 deletions(-) create mode 100644 UACloudLibraryServer/wwwroot/images/OPC UA Logo square.png diff --git a/README.md b/README.md index 51e55d8e..cd7a8a92 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,38 @@ Hosting on AWS requires the identity/role used to have policies allowing access Hosting on GCP requires an identity used to have policies allowing access to the GCS bucket. In case file based authentication is used, please set the envionment variable GOOGLE_APPLICATION_CREDENTIALS pointing to the SA-Key. +## Microsoft Identity Platform Login (aka Azure AD, Microsoft Entra Id) + +1. Create an application registration for an ASP.Net web app using Microsoft identity, as per the [documentation](https://learn.microsoft.com/en-us/azure/active-directory/develop/scenario-web-app-sign-user-app-registration?tabs=aspnetcore). + + Specifically: + + - Redirect UIs: + + https://(servername)/Identity/Account/ExternalLogin + + https://(servername)/signin-oidc + + https://(servername)/ + + - Front Channel logout URL: + + https://(servername)/signout-oidc + + - Select ID tokens (no need for Access tokens). + +2. Configure the server to use the application: + +```json + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "ClientId": "", //"[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]", + "TenantId": "", //"[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App + } +``` + +You can use the corresponding environment variables (AzureAd__XYZ ) or Azure configuration names (AzureAd:XYZ). + ## Deployment Docker containers are automatically built for the UA Cloud Library. The latest version is always available via: diff --git a/UACloudLibraryServer/Views/Home/Index.cshtml b/UACloudLibraryServer/Views/Home/Index.cshtml index f46dd856..fd5f5589 100644 --- a/UACloudLibraryServer/Views/Home/Index.cshtml +++ b/UACloudLibraryServer/Views/Home/Index.cshtml @@ -5,130 +5,197 @@
-

Welcome to the CESMII UA Cloud Library

-

- The CESMII UA Cloud Library is hosted by CESMII, the Clean Energy Smart Manufacturing Institute! - This Cloud Library contains curated node sets created by CESMII or its members, as well as node sets from the OPC Foundation Cloud Library. - Get started by using any of the tools and resources below. -

+

Welcome to the CESMII UA Cloud Library

+

+ The CESMII UA Cloud Library is hosted by CESMII, the Clean Energy Smart Manufacturing Institute! + This Cloud Library contains curated node sets created by CESMII or its members, as well as node sets from the OPC Foundation Cloud Library. + Get started by using any of the tools and resources below. +

+
+

Explore the Cloud Library:

+
+
- -
-
-
- cesmii-icon - Cloud Library Explorer + +
- -
-
-
- swagger-icon - Swagger UI + + +
+

Developer resources:

+
+
-
-
-
- graphql-playground-icon - GraphQL Playground -
-
-
-
-
- graphql-editor-icon - GraphQL Editor -
-
- -
-
-
- cloudlibrary-viewer-icon - UANodesetWebViewer -
-
- -
-
- diff --git a/UACloudLibraryServer/wwwroot/images/OPC UA Logo square.png b/UACloudLibraryServer/wwwroot/images/OPC UA Logo square.png new file mode 100644 index 0000000000000000000000000000000000000000..ad9e7d4ccf5d6d677efeaa82ea5c41e4383c535e GIT binary patch literal 22920 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 literal 0 HcmV?d00001 From aa5e816419409a71e27eb43d613f77a99bfc7890 Mon Sep 17 00:00:00 2001 From: Markus Horstmann Date: Thu, 5 Oct 2023 14:57:24 -0700 Subject: [PATCH 8/8] Readme: add app role info --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cd7a8a92..827ab71b 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,14 @@ In case file based authentication is used, please set the envionment variable GO https://(servername)/signout-oidc - Select ID tokens (no need for Access tokens). - -2. Configure the server to use the application: + +2. Add an Administrator App role: + - Name and Description per your conventions + - Value must be "Administrator" + +3. Assign administrator role to the desired users. + +4. Configure the server to use the application: ```json "AzureAd": {