Merge from Master
This commit is contained in:
commit
d6c6582b51
@ -43,7 +43,7 @@ You can download them and start reviewing these Guides/eBooks here:
|
|||||||
| Architecting & Developing | Containers Lifecycle & CI/CD | App patterns with Xamarin.Forms |
|
| Architecting & Developing | Containers Lifecycle & CI/CD | App patterns with Xamarin.Forms |
|
||||||
| ------------ | ------------| ------------|
|
| ------------ | ------------| ------------|
|
||||||
| <a href='https://aka.ms/microservicesebook'><img src="img/ebook_arch_dev_microservices_containers_cover.png"> </a> | <a href='https://aka.ms/dockerlifecycleebook'> <img src="img/ebook_containers_lifecycle.png"> </a> | <a href='https://aka.ms/xamarinpatternsebook'> <img src="img/xamarin-enterprise-patterns-ebook-cover-small.png"> </a> |
|
| <a href='https://aka.ms/microservicesebook'><img src="img/ebook_arch_dev_microservices_containers_cover.png"> </a> | <a href='https://aka.ms/dockerlifecycleebook'> <img src="img/ebook_containers_lifecycle.png"> </a> | <a href='https://aka.ms/xamarinpatternsebook'> <img src="img/xamarin-enterprise-patterns-ebook-cover-small.png"> </a> |
|
||||||
| <sup> <a href='https://aka.ms/microservicesebook'>**Download** (First Edition)</a> </sup> | <sup> <a href='https://aka.ms/dockerlifecycleebook'>**Download** (First Edition from late 2016) </a> </sup> | <sup> <a href='https://aka.ms/xamarinpatternsebook'>**Download** (Preview Edition) </a> </sup> |
|
| <sup> <a href='https://aka.ms/microservicesebook'>**Download** (First Edition)</a> </sup> | <sup> <a href='https://aka.ms/dockerlifecycleebook'>**Download** (First Edition from late 2016) </a> </sup> | <sup> <a href='https://aka.ms/xamarinpatternsebook'>**Download** (First Edition) </a> </sup> |
|
||||||
|
|
||||||
Send feedback to [dotnet-architecture-ebooks-feedback@service.microsoft.com](dotnet-architecture-ebooks-feedback@service.microsoft.com)
|
Send feedback to [dotnet-architecture-ebooks-feedback@service.microsoft.com](dotnet-architecture-ebooks-feedback@service.microsoft.com)
|
||||||
<p>
|
<p>
|
||||||
@ -76,7 +76,7 @@ Finally, those microservices are consumed by multiple client web and mobile apps
|
|||||||
## Setting up your development environment for eShopOnContainers
|
## Setting up your development environment for eShopOnContainers
|
||||||
### Visual Studio 2017 and Windows based
|
### Visual Studio 2017 and Windows based
|
||||||
This is the more straightforward way to get started:
|
This is the more straightforward way to get started:
|
||||||
https://github.com/dotnet/eShopOnContainers/wiki/02.-Setting-eShopOnContainer-solution-up-in-a-Visual-Studio-2017-environment
|
https://github.com/dotnet-architecture/eShopOnContainers/wiki/02.-Setting-eShopOnContainers-in-a-Visual-Studio-2017-environment
|
||||||
|
|
||||||
### CLI and Windows based
|
### CLI and Windows based
|
||||||
For those who prefer the CLI on Windows, using dotnet CLI, docker CLI and VS Code for Windows:
|
For those who prefer the CLI on Windows, using dotnet CLI, docker CLI and VS Code for Windows:
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 38 KiB |
@ -1,12 +1,12 @@
|
|||||||
#eShopOnContainers
|
# eShopOnContainers
|
||||||
|
|
||||||
eShopOnContainers is a reference app whose imagined purpose is to serve the mobile workforce of a fictitious company that sells products. The app allow to manage the catalog, view products, manage the basket and the orders.
|
eShopOnContainers is a reference app whose imagined purpose is to serve the mobile workforce of a fictitious company that sells products. The app allow to manage the catalog, view products, manage the basket and the orders.
|
||||||
|
|
||||||
<img src="Images/eShopOnContainers_Architecture_Diagram.png" alt="eShopOnContainers" Width="800" />
|
<img src="Images/eShopOnContainers_Architecture_Diagram.png" alt="eShopOnContainers" Width="800" />
|
||||||
|
|
||||||
###Supported platforms: iOS, Android and Windows
|
### Supported platforms: iOS, Android and Windows
|
||||||
|
|
||||||
###The app architecture consists of two parts:
|
### The app architecture consists of two parts:
|
||||||
1. A Xamarin.Forms mobile app for iOS, Android and Windows.
|
1. A Xamarin.Forms mobile app for iOS, Android and Windows.
|
||||||
2. Several .NET Web API microservices deployed as Docker containers.
|
2. Several .NET Web API microservices deployed as Docker containers.
|
||||||
|
|
||||||
@ -34,7 +34,7 @@ This project exercises the following platforms, frameworks or features:
|
|||||||
* Entity Framework
|
* Entity Framework
|
||||||
* Identity Server 4
|
* Identity Server 4
|
||||||
|
|
||||||
##Three platforms
|
## Three platforms
|
||||||
The app targets **three** platforms:
|
The app targets **three** platforms:
|
||||||
|
|
||||||
* iOS
|
* iOS
|
||||||
@ -45,7 +45,7 @@ The app targets **three** platforms:
|
|||||||
|
|
||||||
As of 07/03/2017, eShopOnContainers features **89.2% code share** (7.2% iOS / 16.7% Android / 8.7% Windows).
|
As of 07/03/2017, eShopOnContainers features **89.2% code share** (7.2% iOS / 16.7% Android / 8.7% Windows).
|
||||||
|
|
||||||
##Licenses
|
## Licenses
|
||||||
|
|
||||||
This project uses some third-party assets with a license that requires attribution:
|
This project uses some third-party assets with a license that requires attribution:
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
public const string MockTag = "Mock";
|
public const string MockTag = "Mock";
|
||||||
public const string DefaultEndpoint = "http://13.88.8.119";
|
public const string DefaultEndpoint = "http://13.88.8.119";
|
||||||
|
|
||||||
|
|
||||||
private string _baseEndpoint;
|
private string _baseEndpoint;
|
||||||
private static readonly GlobalSetting _instance = new GlobalSetting();
|
private static readonly GlobalSetting _instance = new GlobalSetting();
|
||||||
|
|
||||||
@ -31,6 +30,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public string ClientId { get { return "xamarin"; }}
|
||||||
|
|
||||||
|
public string ClientSecret { get { return "secret"; }}
|
||||||
|
|
||||||
public string AuthToken { get; set; }
|
public string AuthToken { get; set; }
|
||||||
|
|
||||||
public string RegisterWebsite { get; set; }
|
public string RegisterWebsite { get; set; }
|
||||||
@ -47,6 +50,8 @@
|
|||||||
|
|
||||||
public string UserInfoEndpoint { get; set; }
|
public string UserInfoEndpoint { get; set; }
|
||||||
|
|
||||||
|
public string TokenEndpoint { get; set; }
|
||||||
|
|
||||||
public string LogoutEndpoint { get; set; }
|
public string LogoutEndpoint { get; set; }
|
||||||
|
|
||||||
public string IdentityCallback { get; set; }
|
public string IdentityCallback { get; set; }
|
||||||
@ -61,6 +66,7 @@
|
|||||||
BasketEndpoint = string.Format("{0}:5103", baseEndpoint);
|
BasketEndpoint = string.Format("{0}:5103", baseEndpoint);
|
||||||
IdentityEndpoint = string.Format("{0}:5105/connect/authorize", baseEndpoint);
|
IdentityEndpoint = string.Format("{0}:5105/connect/authorize", baseEndpoint);
|
||||||
UserInfoEndpoint = string.Format("{0}:5105/connect/userinfo", baseEndpoint);
|
UserInfoEndpoint = string.Format("{0}:5105/connect/userinfo", baseEndpoint);
|
||||||
|
TokenEndpoint = string.Format("{0}:5105/connect/token", baseEndpoint);
|
||||||
LogoutEndpoint = string.Format("{0}:5105/connect/endsession", baseEndpoint);
|
LogoutEndpoint = string.Format("{0}:5105/connect/endsession", baseEndpoint);
|
||||||
IdentityCallback = string.Format("{0}:5105/xamarincallback", baseEndpoint);
|
IdentityCallback = string.Format("{0}:5105/xamarincallback", baseEndpoint);
|
||||||
LogoutCallback = string.Format("{0}:5105/Account/Redirecting", baseEndpoint);
|
LogoutCallback = string.Format("{0}:5105/Account/Redirecting", baseEndpoint);
|
||||||
|
@ -0,0 +1,22 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace eShopOnContainers.Core.Models.Token
|
||||||
|
{
|
||||||
|
public class UserToken
|
||||||
|
{
|
||||||
|
[JsonProperty("id_token")]
|
||||||
|
public string IdToken { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("access_token")]
|
||||||
|
public string AccessToken { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("expires_in")]
|
||||||
|
public int ExpiresIn { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("token_type")]
|
||||||
|
public string TokenType { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("refresh_token")]
|
||||||
|
public string RefreshToken { get; set; }
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,12 @@
|
|||||||
namespace eShopOnContainers.Core.Services.Identity
|
using eShopOnContainers.Core.Models.Token;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace eShopOnContainers.Core.Services.Identity
|
||||||
{
|
{
|
||||||
public interface IIdentityService
|
public interface IIdentityService
|
||||||
{
|
{
|
||||||
string CreateAuthorizationRequest();
|
string CreateAuthorizationRequest();
|
||||||
string CreateLogoutRequest(string token);
|
string CreateLogoutRequest(string token);
|
||||||
|
Task<UserToken> GetTokenAsync(string code);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,11 +1,22 @@
|
|||||||
using IdentityModel.Client;
|
using IdentityModel.Client;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using eShopOnContainers.Core.Services.RequestProvider;
|
||||||
|
using eShopOnContainers.Core.Models.Token;
|
||||||
|
|
||||||
namespace eShopOnContainers.Core.Services.Identity
|
namespace eShopOnContainers.Core.Services.Identity
|
||||||
{
|
{
|
||||||
public class IdentityService : IIdentityService
|
public class IdentityService : IIdentityService
|
||||||
{
|
{
|
||||||
|
private readonly IRequestProvider _requestProvider;
|
||||||
|
|
||||||
|
public IdentityService(IRequestProvider requestProvider)
|
||||||
|
{
|
||||||
|
_requestProvider = requestProvider;
|
||||||
|
}
|
||||||
|
|
||||||
public string CreateAuthorizationRequest()
|
public string CreateAuthorizationRequest()
|
||||||
{
|
{
|
||||||
// Create URI to authorization endpoint
|
// Create URI to authorization endpoint
|
||||||
@ -13,11 +24,10 @@ namespace eShopOnContainers.Core.Services.Identity
|
|||||||
|
|
||||||
// Dictionary with values for the authorize request
|
// Dictionary with values for the authorize request
|
||||||
var dic = new Dictionary<string, string>();
|
var dic = new Dictionary<string, string>();
|
||||||
dic.Add("client_id", "xamarin");
|
dic.Add("client_id", GlobalSetting.Instance.ClientId);
|
||||||
dic.Add("client_secret", "secret");
|
dic.Add("client_secret", GlobalSetting.Instance.ClientSecret);
|
||||||
dic.Add("response_type", "code id_token token");
|
dic.Add("response_type", "code id_token");
|
||||||
dic.Add("scope", "openid profile basket orders locations offline_access");
|
dic.Add("scope", "openid profile basket orders locations offline_access");
|
||||||
|
|
||||||
dic.Add("redirect_uri", GlobalSetting.Instance.IdentityCallback);
|
dic.Add("redirect_uri", GlobalSetting.Instance.IdentityCallback);
|
||||||
dic.Add("nonce", Guid.NewGuid().ToString("N"));
|
dic.Add("nonce", Guid.NewGuid().ToString("N"));
|
||||||
|
|
||||||
@ -31,7 +41,7 @@ namespace eShopOnContainers.Core.Services.Identity
|
|||||||
|
|
||||||
public string CreateLogoutRequest(string token)
|
public string CreateLogoutRequest(string token)
|
||||||
{
|
{
|
||||||
if(string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
{
|
{
|
||||||
return string.Empty;
|
return string.Empty;
|
||||||
}
|
}
|
||||||
@ -41,5 +51,12 @@ namespace eShopOnContainers.Core.Services.Identity
|
|||||||
token,
|
token,
|
||||||
GlobalSetting.Instance.LogoutCallback);
|
GlobalSetting.Instance.LogoutCallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserToken> GetTokenAsync(string code)
|
||||||
|
{
|
||||||
|
string data = string.Format("grant_type=authorization_code&code={0}&redirect_uri={1}", code, WebUtility.UrlEncode(GlobalSetting.Instance.IdentityCallback));
|
||||||
|
var token = await _requestProvider.PostAsync<UserToken>(GlobalSetting.Instance.TokenEndpoint, data, GlobalSetting.Instance.ClientId, GlobalSetting.Instance.ClientSecret);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,8 @@ namespace eShopOnContainers.Core.Services.RequestProvider
|
|||||||
|
|
||||||
Task<TResult> PostAsync<TResult>(string uri, TResult data, string token = "", string header = "");
|
Task<TResult> PostAsync<TResult>(string uri, TResult data, string token = "", string header = "");
|
||||||
|
|
||||||
|
Task<TResult> PostAsync<TResult>(string uri, string data, string clientId, string clientSecret);
|
||||||
|
|
||||||
Task DeleteAsync(string uri, string token = "");
|
Task DeleteAsync(string uri, string token = "");
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -61,6 +61,28 @@ namespace eShopOnContainers.Core.Services.RequestProvider
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<TResult> PostAsync<TResult>(string uri, string data, string clientId, string clientSecret)
|
||||||
|
{
|
||||||
|
HttpClient httpClient = CreateHttpClient(string.Empty);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(clientId) && !string.IsNullOrWhiteSpace(clientSecret))
|
||||||
|
{
|
||||||
|
AddBasicAuthenticationHeader(httpClient, clientId, clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = new StringContent(data);
|
||||||
|
content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||||
|
HttpResponseMessage response = await httpClient.PostAsync(uri, content);
|
||||||
|
|
||||||
|
await HandleResponse(response);
|
||||||
|
string serialized = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
TResult result = await Task.Run(() =>
|
||||||
|
JsonConvert.DeserializeObject<TResult>(serialized, _serializerSettings));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(string uri, string token = "")
|
public async Task DeleteAsync(string uri, string token = "")
|
||||||
{
|
{
|
||||||
HttpClient httpClient = CreateHttpClient(token);
|
HttpClient httpClient = CreateHttpClient(token);
|
||||||
@ -90,6 +112,17 @@ namespace eShopOnContainers.Core.Services.RequestProvider
|
|||||||
httpClient.DefaultRequestHeaders.Add(parameter, Guid.NewGuid().ToString());
|
httpClient.DefaultRequestHeaders.Add(parameter, Guid.NewGuid().ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddBasicAuthenticationHeader(HttpClient httpClient, string clientId, string clientSecret)
|
||||||
|
{
|
||||||
|
if (httpClient == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(clientId) || string.IsNullOrWhiteSpace(clientSecret))
|
||||||
|
return;
|
||||||
|
|
||||||
|
httpClient.DefaultRequestHeaders.Authorization = new BasicAuthenticationHeaderValue(clientId, clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleResponse(HttpResponseMessage response)
|
private async Task HandleResponse(HttpResponseMessage response)
|
||||||
{
|
{
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
|
@ -203,16 +203,15 @@ namespace eShopOnContainers.Core.ViewModels
|
|||||||
private void Logout()
|
private void Logout()
|
||||||
{
|
{
|
||||||
var authIdToken = Settings.AuthIdToken;
|
var authIdToken = Settings.AuthIdToken;
|
||||||
|
|
||||||
var logoutRequest = _identityService.CreateLogoutRequest(authIdToken);
|
var logoutRequest = _identityService.CreateLogoutRequest(authIdToken);
|
||||||
|
|
||||||
if(!string.IsNullOrEmpty(logoutRequest))
|
if (!string.IsNullOrEmpty(logoutRequest))
|
||||||
{
|
{
|
||||||
// Logout
|
// Logout
|
||||||
LoginUrl = logoutRequest;
|
LoginUrl = logoutRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(Settings.UseMocks)
|
if (Settings.UseMocks)
|
||||||
{
|
{
|
||||||
Settings.AuthAccessToken = string.Empty;
|
Settings.AuthAccessToken = string.Empty;
|
||||||
Settings.AuthIdToken = string.Empty;
|
Settings.AuthIdToken = string.Empty;
|
||||||
@ -235,12 +234,14 @@ namespace eShopOnContainers.Core.ViewModels
|
|||||||
else if (unescapedUrl.Contains(GlobalSetting.Instance.IdentityCallback))
|
else if (unescapedUrl.Contains(GlobalSetting.Instance.IdentityCallback))
|
||||||
{
|
{
|
||||||
var authResponse = new AuthorizeResponse(url);
|
var authResponse = new AuthorizeResponse(url);
|
||||||
|
if (!string.IsNullOrWhiteSpace(authResponse.Code))
|
||||||
if (!string.IsNullOrWhiteSpace(authResponse.AccessToken))
|
|
||||||
{
|
{
|
||||||
if (authResponse.AccessToken != null)
|
var userToken = await _identityService.GetTokenAsync(authResponse.Code);
|
||||||
|
string accessToken = userToken.AccessToken;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(accessToken))
|
||||||
{
|
{
|
||||||
Settings.AuthAccessToken = authResponse.AccessToken;
|
Settings.AuthAccessToken = accessToken;
|
||||||
Settings.AuthIdToken = authResponse.IdentityToken;
|
Settings.AuthIdToken = authResponse.IdentityToken;
|
||||||
|
|
||||||
await NavigationService.NavigateToAsync<MainViewModel>();
|
await NavigationService.NavigateToAsync<MainViewModel>();
|
||||||
|
@ -170,6 +170,7 @@
|
|||||||
<Compile Include="Converters\FirstValidationErrorConverter.cs" />
|
<Compile Include="Converters\FirstValidationErrorConverter.cs" />
|
||||||
<Compile Include="Effects\EntryLineColorEffect.cs" />
|
<Compile Include="Effects\EntryLineColorEffect.cs" />
|
||||||
<Compile Include="Behaviors\LineColorBehavior.cs" />
|
<Compile Include="Behaviors\LineColorBehavior.cs" />
|
||||||
|
<Compile Include="Models\Token\UserToken.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="app.config" />
|
<None Include="app.config" />
|
||||||
@ -263,6 +264,9 @@
|
|||||||
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
|
<Generator>MSBuild:UpdateDesignTimeXaml</Generator>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Models\Token\" />
|
||||||
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Reference Include="System.ComponentModel.Annotations">
|
<Reference Include="System.ComponentModel.Annotations">
|
||||||
<HintPath>..\..\..\..\..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETPortable\v4.6\Profile\Profile44\System.ComponentModel.Annotations.dll</HintPath>
|
<HintPath>..\..\..\..\..\..\..\..\Program Files (x86)\Reference Assemblies\Microsoft\Framework\.NETPortable\v4.6\Profile\Profile44\System.ComponentModel.Annotations.dll</HintPath>
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"Xamarin.Forms": "2.3.4.231",
|
"Xamarin.Forms": "2.3.4.231",
|
||||||
"xunit": "2.2.0"
|
"xunit": "2.2.0",
|
||||||
|
"xunit.runner.console": "2.2.0"
|
||||||
},
|
},
|
||||||
"frameworks": {
|
"frameworks": {
|
||||||
".NETPortable,Version=v4.5,Profile=Profile111": {}
|
".NETPortable,Version=v4.5,Profile=Profile111": {}
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
<AssemblyName>eShopOnContainersiOS</AssemblyName>
|
<AssemblyName>eShopOnContainersiOS</AssemblyName>
|
||||||
<NuGetPackageImportStamp>
|
<NuGetPackageImportStamp>
|
||||||
</NuGetPackageImportStamp>
|
</NuGetPackageImportStamp>
|
||||||
|
<SkipValidatePackageReferences>true</SkipValidatePackageReferences>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|iPhoneSimulator' ">
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|iPhoneSimulator' ">
|
||||||
<DebugSymbols>true</DebugSymbols>
|
<DebugSymbols>true</DebugSymbols>
|
||||||
|
@ -74,7 +74,7 @@ namespace Identity.API.Services
|
|||||||
if (!string.IsNullOrWhiteSpace(user.Name))
|
if (!string.IsNullOrWhiteSpace(user.Name))
|
||||||
claims.Add(new Claim("name", user.Name));
|
claims.Add(new Claim("name", user.Name));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(user.Name))
|
if (!string.IsNullOrWhiteSpace(user.LastName))
|
||||||
claims.Add(new Claim("last_name", user.LastName));
|
claims.Add(new Claim("last_name", user.LastName));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(user.CardNumber))
|
if (!string.IsNullOrWhiteSpace(user.CardNumber))
|
||||||
|
Loading…
x
Reference in New Issue
Block a user