diff --git a/CloudLibSync/CloudLibSync.cs b/CloudLibSync/CloudLibSync.cs index 5a70e676..09be2036 100644 --- a/CloudLibSync/CloudLibSync.cs +++ b/CloudLibSync/CloudLibSync.cs @@ -117,7 +117,6 @@ public async Task SynchronizeAsync(string sourceUrl, string sourceUserName, stri targetNodesets.AddRange(targetNodeSetResult.Edges.Select(e => e.Node)); targetCursor = targetNodeSetResult.PageInfo.EndCursor; } while (targetNodeSetResult.PageInfo.HasNextPage); - bAdded = false; GraphQlResult sourceNodeSetResult; 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 @@ - + 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/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/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/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 bebdb29c..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)); @@ -261,7 +262,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; @@ -402,20 +403,22 @@ 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; - var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null); + 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,23 +447,58 @@ 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); + 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); + uploadedIdentifier = response.Message; + var approvalResult = await client.UpdateApprovalStatusAsync(uploadedIdentifier, "APPROVED", null, null).ConfigureAwait(false); Assert.NotNull(approvalResult); Assert.Equal("APPROVED", approvalResult.ApprovalStatus); } @@ -474,6 +513,24 @@ public async Task UpdateNodeSet(string path, string fileName, bool uploadConflic } } 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/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/UA-CloudLibrary.sln b/UA-CloudLibrary.sln index d79a5369..163d5613 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/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 index c802cc20..c799cfc7 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml @@ -1,26 +1,26 @@ -@page -@model ConfirmEmailModel -@{ - ViewData["Title"] = "Confirm Email"; - - var _alertCss = Model.Succeeded ? "alert alert-success" : "alert alert-danger"; -} - -

@ViewData["Title"]

- -
-
- -
-
- -@if (Model.Succeeded) -{ -
-
- Login -
-
-} +@page +@model ConfirmEmailModel +@{ + ViewData["Title"] = "Confirm Email"; + + var _alertCss = Model.Succeeded ? "alert alert-success" : "alert alert-danger"; +} + +

@ViewData["Title"]

+ +
+
+ +
+
+ +@if (Model.Succeeded) +{ +
+
+ Login +
+
+} diff --git a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs index 0d779d30..1500cb26 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs @@ -40,14 +40,14 @@ public async Task OnGetAsync(string userId, string code) return RedirectToPage("/Index"); } - var user = await _userManager.FindByIdAsync(userId); + 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); + var result = await _userManager.ConfirmEmailAsync(user, code).ConfigureAwait(false); Succeeded = result.Succeeded; 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..da52717a --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs @@ -0,0 +1,221 @@ +// 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 index 43aa1120..613aa5b5 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml @@ -1,26 +1,26 @@ -@page -@model ForgotPasswordModel -@{ - ViewData["Title"] = "Forgot your password?"; -} - -

@ViewData["Title"]

-

Enter your email.

-
-
-
-
-
-
- - - -
- -
-
-
- -@section Scripts { - -} +@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 index a209ddcd..d12f4d56 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs @@ -53,8 +53,8 @@ public async Task OnPostAsync() { if (ModelState.IsValid) { - var user = await _userManager.FindByEmailAsync(Input.Email); - if (user == null || !(await _userManager.IsEmailConfirmedAsync(user))) + 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"); @@ -62,7 +62,7 @@ public async Task OnPostAsync() // 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); + var code = await _userManager.GeneratePasswordResetTokenAsync(user).ConfigureAwait(false); code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); var callbackUrl = Url.Page( "/Account/ResetPassword", @@ -80,7 +80,7 @@ public async Task OnPostAsync() sbBody.AppendLine("

Sincerely,
CESMII DevOps Team

"); await _emailSender.SendEmailAsync(Input.Email, "CESMII | Cloud Library | Reset Password", - sbBody.ToString()); + sbBody.ToString()).ConfigureAwait(false); return RedirectToPage("./ForgotPasswordConfirmation"); } 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 eec993a5..98a71bd9 100644 --- a/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Login.cshtml @@ -10,41 +10,62 @@
- @*

Use a local account to log in.

*@ +

Use a local account to log in:


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

Use another account to log in:

+
+
+
+

+ @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..63481e2a --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Email.cshtml.cs @@ -0,0 +1,169 @@ +// 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..05b250fb --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/Manage/Index.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.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 f965dfb5..77b628f9 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,76 +20,114 @@ 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); //notify registering user @@ -108,7 +150,7 @@ await _emailSender.SendEmailAsync(Input.Email, "CESMII | Cloud Library | New Acc sbBody2.AppendLine($"

User '{Input.Email}' created an account on the CESMII UA Cloud Library. "); sbBody2.AppendLine("

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.

"); sbBody2.AppendLine("

Sincerely,
CESMII DevOps Team

"); - await _emailSender.SendEmailAsync("devops@cesmii.org", "CESMII | Cloud Library | New Account Sign Up", sbBody2.ToString()); + await _emailSender.SendEmailAsync("devops@cesmii.org", "CESMII | Cloud Library | New Account Sign Up", sbBody2.ToString()).ConfigureAwait(false); if (_userManager.Options.SignIn.RequireConfirmedAccount) { @@ -116,7 +158,7 @@ await _emailSender.SendEmailAsync(Input.Email, "CESMII | Cloud Library | New Acc } else { - await _signInManager.SignInAsync(user, isPersistent: false); + await _signInManager.SignInAsync(user, isPersistent: false).ConfigureAwait(false); return LocalRedirect(returnUrl); } } @@ -129,5 +171,28 @@ await _emailSender.SendEmailAsync(Input.Email, "CESMII | Cloud Library | New Acc // 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..6cb85127 --- /dev/null +++ b/UACloudLibraryServer/Areas/Identity/Pages/Account/ResetPassword.cshtml.cs @@ -0,0 +1,114 @@ +// 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..39ae6d58 --- /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/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 e9e56e00..d869f4a0 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 e79914fc..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; @@ -63,55 +63,12 @@ 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 }; } _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 }); - 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); - if (user == null) - { - return NotFound(); - } - var result = await userManager.AddToRoleAsync(user, roleName); - if (!result.Succeeded) - { - return this.BadRequest(result); - } - return new ObjectResult("User role added successfully") { StatusCode = (int)HttpStatusCode.OK }; - } } } 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 35b1912f..6dd71e3b 100644 --- a/UACloudLibraryServer/Controllers/InfoModelController.cs +++ b/UACloudLibraryServer/Controllers/InfoModelController.cs @@ -27,13 +27,14 @@ * 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; @@ -42,8 +43,6 @@ namespace Opc.Ua.Cloud.Library 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 +126,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,35 +140,49 @@ 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 }; } -#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); @@ -177,7 +190,6 @@ public async Task DeleteNamespaceAsync( return new ObjectResult(uaNamespace) { StatusCode = (int)HttpStatusCode.OK }; } -#endif [HttpPut] [Route("/infomodel/upload")] @@ -191,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 { @@ -231,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 2af6a8db..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; @@ -61,7 +58,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..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; @@ -152,7 +153,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 +267,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 +378,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/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 ad464008..93e59406 100644 --- a/UACloudLibraryServer/Startup.cs +++ b/UACloudLibraryServer/Startup.cs @@ -30,16 +30,22 @@ 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.Authorization; + using Microsoft.AspNetCore.Authentication.OAuth; 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; @@ -48,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 { @@ -74,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(); @@ -96,11 +108,75 @@ public void ConfigureServices(IServiceCollection services) services.AddLogging(builder => builder.AddConsole()); services.AddAuthentication() - .AddScheme("BasicAuthentication", null); + .AddScheme("BasicAuthentication", null) + ; + + 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.AccessDeniedPath = new PathString("/Account/AccessDenied"); + options.CallbackPath = new PathString("/Account/ExternalLogin"); + + options.ClientId = Configuration["OAuth2ClientId"]; + options.ClientSecret = Configuration["OAuth2ClientSecret"]; + + 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"); + + 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); + } + }; + }); + } + +#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 => { @@ -115,12 +191,9 @@ public void ConfigureServices(IServiceCollection services) } }); - options.AddSecurityDefinition("basic", new OpenApiSecurityScheme { - Name = "Authorization", + options.AddSecurityDefinition("basicAuth", new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, - Scheme = "basic", - In = ParameterLocation.Header, - Description = "Basic Authorization header using the Bearer scheme." + Scheme = "basic" }); options.AddSecurityRequirement(new OpenApiSecurityRequirement @@ -131,7 +204,7 @@ public void ConfigureServices(IServiceCollection services) Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, - Id = "basic" + Id = "basicAuth" } }, Array.Empty() @@ -178,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(); @@ -210,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) { @@ -245,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 => { @@ -256,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 8cac887d..38f19db2 100644 --- a/UACloudLibraryServer/UA-CloudLibrary.csproj +++ b/UACloudLibraryServer/UA-CloudLibrary.csproj @@ -1,86 +1,89 @@  - - net6.0 - Opc.Ua.Cloud.Library - 78fb557e-0608-425b-a56c-dfebc15e2b58 - Linux - ./.. - ..\docker-compose.dcproj - $(DEFINECONSTANTS);NOLEGACY - + + 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 + + + + + + + + + + + + + + + + + + diff --git a/UACloudLibraryServer/UserService.cs b/UACloudLibraryServer/UserService.cs index 866d802b..ccf8efc7 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; } }