diff --git a/src/WebExpress.WebApp.Test/WebIdentity/UnitTestIdentityProvider.cs b/src/WebExpress.WebApp.Test/WebIdentity/UnitTestIdentityProvider.cs new file mode 100644 index 0000000..4828ce3 --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebIdentity/UnitTestIdentityProvider.cs @@ -0,0 +1,137 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebIdentity; +using WebExpress.WebCore.WebIdentity; + +namespace WebExpress.WebApp.Test.WebIdentity +{ + /// + /// Tests the abstract identity provider base class. + /// + [Collection("NonParallelTests")] + public class UnitTestIdentityProvider + { + /// + /// A minimal concrete implementation for testing. + /// + private sealed class TestIdentityProvider : IdentityProvider + { + public bool ValidateBasicCalled { get; private set; } + public bool ValidateTokenCalled { get; private set; } + public string LastUsername { get; private set; } + public string LastToken { get; private set; } + public IIdentity IdentityToReturn { get; set; } + + public override IEnumerable GetIdentities() => []; + + public override IEnumerable GetGroups() => []; + + protected override IIdentity ValidateBasicCredentials(string username, string password) + { + ValidateBasicCalled = true; + LastUsername = username; + return IdentityToReturn; + } + + protected override IIdentity ValidateToken(string token) + { + ValidateTokenCalled = true; + LastToken = token; + return IdentityToReturn; + } + } + + /// + /// Tests that Authenticate returns null when request is null. + /// + [Fact] + public void Authenticate_NullRequest_ReturnsNull() + { + // arrange + _ = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var provider = new TestIdentityProvider(); + + // act + var result = provider.Authenticate(null); + + // validation + Assert.Null(result); + } + + /// + /// Tests that Authenticate returns null when the request has no authorization header. + /// + [Fact] + public void Authenticate_NoAuthorizationHeader_ReturnsNull() + { + // arrange + _ = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var provider = new TestIdentityProvider(); + var request = UnitTestControlFixture.CreateRequestMock(); + + // act + var result = provider.Authenticate(request); + + // validation + Assert.Null(result); + } + + /// + /// Tests that Authenticate calls ValidateBasicCredentials for Basic auth headers. + /// + [Fact] + public void Authenticate_BasicAuthHeader_CallsValidateBasicCredentials() + { + // arrange + _ = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var provider = new TestIdentityProvider(); + var content = $"GET / HTTP/1.1\r\nHost: localhost\r\nAuthorization: Basic {Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("user:pass"))}\r\n\r\n"; + var request = UnitTestControlFixture.CreateRequestMock(content, ""); + + // act + provider.Authenticate(request); + + // validation + Assert.True(provider.ValidateBasicCalled); + } + + /// + /// Tests that Authenticate calls ValidateToken for Bearer auth headers. + /// + [Fact] + public void Authenticate_BearerAuthHeader_CallsValidateToken() + { + // arrange + _ = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var provider = new TestIdentityProvider(); + // bearer token must be base64-encoded as ":" so Identification contains the token + var bearerToken = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("mytoken123:")); + var content = $"GET / HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {bearerToken}\r\n\r\n"; + var request = UnitTestControlFixture.CreateRequestMock(content, ""); + + // act + provider.Authenticate(request); + + // validation + Assert.True(provider.ValidateTokenCalled); + } + + /// + /// Tests that GetIdentities and GetGroups can be called on the provider. + /// + [Fact] + public void GetIdentities_ReturnsEmpty() + { + // arrange + _ = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var provider = new TestIdentityProvider(); + + // act + var identities = provider.GetIdentities(); + var groups = provider.GetGroups(); + + // validation + Assert.Empty(identities); + Assert.Empty(groups); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppForbidden.cs b/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppForbidden.cs new file mode 100644 index 0000000..cd8771a --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppForbidden.cs @@ -0,0 +1,101 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebPage; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebPage +{ + /// + /// Tests the access-denied page. + /// + [Collection("NonParallelTests")] + public class UnitTestPageWebAppForbidden + { + /// + /// Tests that the forbidden page can be processed without throwing an exception. + /// + [Fact] + public void Process_DoesNotThrow() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppForbidden(); + + // act + var exception = Record.Exception(() => page.Process(context, visualTree)); + + // validation + Assert.Null(exception); + } + + /// + /// Tests that the forbidden page populates the visual tree content area. + /// + [Fact] + public void Process_PopulatesContent() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppForbidden(); + + // act + page.Process(context, visualTree); + + // validation + Assert.NotEmpty(visualTree.Content.MainPanel.Primary); + } + + /// + /// Tests that the forbidden page sets the visual tree title. + /// + [Fact] + public void Process_SetsTitle() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppForbidden(); + + // act + page.Process(context, visualTree); + + // validation + Assert.NotNull(visualTree.Title); + } + + /// + /// Tests that Process throws ArgumentNullException when renderContext is null. + /// + [Fact] + public void Process_NullRenderContext_ThrowsArgumentNullException() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppForbidden(); + + // act & validation + Assert.Throws(() => page.Process(null, visualTree)); + } + + /// + /// Tests that Process throws ArgumentNullException when visualTree is null. + /// + [Fact] + public void Process_NullVisualTree_ThrowsArgumentNullException() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var page = new PageWebAppForbidden(); + + // act & validation + Assert.Throws(() => page.Process(context, null)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppLogin.cs b/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppLogin.cs new file mode 100644 index 0000000..328b74d --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppLogin.cs @@ -0,0 +1,101 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebPage; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebPage +{ + /// + /// Tests the login page. + /// + [Collection("NonParallelTests")] + public class UnitTestPageWebAppLogin + { + /// + /// Tests that the login page can be processed without throwing an exception. + /// + [Fact] + public void Process_DoesNotThrow() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogin(); + + // act + var exception = Record.Exception(() => page.Process(context, visualTree)); + + // validation + Assert.Null(exception); + } + + /// + /// Tests that the login page populates the visual tree content area. + /// + [Fact] + public void Process_PopulatesContent() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogin(); + + // act + page.Process(context, visualTree); + + // validation + Assert.NotEmpty(visualTree.Content.MainPanel.Primary); + } + + /// + /// Tests that the login page sets the visual tree title. + /// + [Fact] + public void Process_SetsTitle() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogin(); + + // act + page.Process(context, visualTree); + + // validation + Assert.NotNull(visualTree.Title); + } + + /// + /// Tests that Process throws ArgumentNullException when renderContext is null. + /// + [Fact] + public void Process_NullRenderContext_ThrowsArgumentNullException() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogin(); + + // act & validation + Assert.Throws(() => page.Process(null, visualTree)); + } + + /// + /// Tests that Process throws ArgumentNullException when visualTree is null. + /// + [Fact] + public void Process_NullVisualTree_ThrowsArgumentNullException() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var page = new PageWebAppLogin(); + + // act & validation + Assert.Throws(() => page.Process(context, null)); + } + } +} diff --git a/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppLogout.cs b/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppLogout.cs new file mode 100644 index 0000000..fa4554f --- /dev/null +++ b/src/WebExpress.WebApp.Test/WebPage/UnitTestPageWebAppLogout.cs @@ -0,0 +1,101 @@ +using WebExpress.WebApp.Test.Fixture; +using WebExpress.WebApp.WebPage; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.Test.WebPage +{ + /// + /// Tests the logout page. + /// + [Collection("NonParallelTests")] + public class UnitTestPageWebAppLogout + { + /// + /// Tests that the logout page can be processed without throwing an exception. + /// + [Fact] + public void Process_DoesNotThrow() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogout(); + + // act + var exception = Record.Exception(() => page.Process(context, visualTree)); + + // validation + Assert.Null(exception); + } + + /// + /// Tests that the logout page populates the visual tree content area. + /// + [Fact] + public void Process_PopulatesContent() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogout(); + + // act + page.Process(context, visualTree); + + // validation + Assert.NotEmpty(visualTree.Content.MainPanel.Primary); + } + + /// + /// Tests that the logout page sets the visual tree title. + /// + [Fact] + public void Process_SetsTitle() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogout(); + + // act + page.Process(context, visualTree); + + // validation + Assert.NotNull(visualTree.Title); + } + + /// + /// Tests that Process throws ArgumentNullException when renderContext is null. + /// + [Fact] + public void Process_NullRenderContext_ThrowsArgumentNullException() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var visualTree = new VisualTreeWebAppLogin(componentHub, context.PageContext); + var page = new PageWebAppLogout(); + + // act & validation + Assert.Throws(() => page.Process(null, visualTree)); + } + + /// + /// Tests that Process throws ArgumentNullException when visualTree is null. + /// + [Fact] + public void Process_NullVisualTree_ThrowsArgumentNullException() + { + // arrange + var componentHub = UnitTestControlFixture.CreateAndRegisterComponentHubMock(); + var context = UnitTestControlFixture.CreateRenderContextMock(); + var page = new PageWebAppLogout(); + + // act & validation + Assert.Throws(() => page.Process(context, null)); + } + } +} diff --git a/src/WebExpress.WebApp/Internationalization/de b/src/WebExpress.WebApp/Internationalization/de index ddfc168..955d575 100644 --- a/src/WebExpress.WebApp/Internationalization/de +++ b/src/WebExpress.WebApp/Internationalization/de @@ -223,4 +223,25 @@ setting.monitor.chart.axis.y=Anzahl setting.monitor.currenttime=Aktuelle Systemzeit setting.monitor.group.chart.resources.label=Systemressourcen setting.monitor.dataset.cpu=CPU Auslastung (%) -setting.monitor.dataset.memory=Speichernutzung (MB) \ No newline at end of file +setting.monitor.dataset.memory=Speichernutzung (MB) +login.title=Anmelden +login.username.label=Benutzername +login.username.placeholder=Benutzername eingeben +login.password.label=Passwort +login.password.placeholder=Passwort eingeben +login.submit.label=Anmelden +login.error.empty=Benutzername und Passwort sind erforderlich. +login.error.invalid=Ungültiger Benutzername oder falsches Passwort. + +logout.title=Abgemeldet +logout.description=Sie wurden erfolgreich abgemeldet. +logout.login.label=Erneut anmelden + +forbidden.title=Zugriff verweigert +forbidden.description=Sie verfügen nicht über die erforderlichen Berechtigungen, um auf diese Ressource zuzugreifen. Bitte wenden Sie sich an Ihren Administrator oder melden Sie sich mit einem Konto an, das über die notwendigen Berechtigungen verfügt. +forbidden.switchaccount.label=Konto wechseln + +status.401.title=Nicht autorisiert +status.401.description=Der HTTP-Statuscode 401 bedeutet, dass die Anfrage nicht bearbeitet wurde, da gültige Authentifizierungsdaten für die Zielressource fehlen.
  • Der Benutzer hat sich nicht authentifiziert.
  • Die angegebenen Anmeldedaten sind ungültig oder abgelaufen.
  • Die Authentifizierungsmethode wird nicht unterstützt.
+status.403.title=Verboten +status.403.description=Der HTTP-Statuscode 403 bedeutet, dass der Server die Anfrage verstanden hat, aber die Autorisierung verweigert. Im Gegensatz zu 401 macht eine erneute Authentifizierung keinen Unterschied.
  • Der authentifizierte Benutzer verfügt nicht über die erforderlichen Berechtigungen.
  • Der Zugriff auf die angeforderte Ressource ist eingeschränkt.
  • Das Benutzerkonto könnte gesperrt oder deaktiviert sein.
diff --git a/src/WebExpress.WebApp/Internationalization/en b/src/WebExpress.WebApp/Internationalization/en index 1bdda97..2dc9048 100644 --- a/src/WebExpress.WebApp/Internationalization/en +++ b/src/WebExpress.WebApp/Internationalization/en @@ -225,3 +225,25 @@ setting.monitor.currenttime=Current System Time setting.monitor.group.chart.resources.label=System Resources setting.monitor.dataset.cpu=CPU Usage (%) setting.monitor.dataset.memory=Memory Usage (MB) + +login.title=Sign in +login.username.label=Username +login.username.placeholder=Enter your username +login.password.label=Password +login.password.placeholder=Enter your password +login.submit.label=Sign in +login.error.empty=Username and password are required. +login.error.invalid=Invalid username or password. + +logout.title=Signed out +logout.description=You have been successfully signed out. +logout.login.label=Sign in again + +forbidden.title=Access denied +forbidden.description=You do not have the required permissions to access this resource. Please contact your administrator or sign in with an account that has the necessary permissions. +forbidden.switchaccount.label=Switch account + +status.401.title=Unauthorized +status.401.description=The HTTP status code 401 indicates that the request was not applied because it lacks valid authentication credentials for the target resource.
  • The user has not authenticated.
  • The provided credentials are invalid or expired.
  • The authentication method is not supported.
+status.403.title=Forbidden +status.403.description=The HTTP status code 403 indicates that the server understood the request but refuses to authorize it. Unlike 401, re-authenticating will make no difference.
  • The authenticated user does not have the required permissions.
  • Access to the requested resource is restricted.
  • The user account may be locked or disabled.
diff --git a/src/WebExpress.WebApp/WebIdentity/IdentityProvider.cs b/src/WebExpress.WebApp/WebIdentity/IdentityProvider.cs new file mode 100644 index 0000000..8de8ca6 --- /dev/null +++ b/src/WebExpress.WebApp/WebIdentity/IdentityProvider.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using WebExpress.WebCore.WebIdentity; +using WebExpress.WebCore.WebMessage; + +namespace WebExpress.WebApp.WebIdentity +{ + /// + /// Provides an abstract base class for identity providers that supply identities and groups + /// and support authentication via HTTP authorization headers. + /// + public abstract class IdentityProvider + { + /// + /// Returns all identities managed by this provider. + /// + /// An enumerable of identities. + public abstract IEnumerable GetIdentities(); + + /// + /// Returns all identity groups managed by this provider. + /// + /// An enumerable of identity groups. + public abstract IEnumerable GetGroups(); + + /// + /// Validates basic authentication credentials and returns the corresponding identity. + /// + /// The provided username. + /// The provided password. + /// The authenticated identity, or null if the credentials are invalid. + protected abstract IIdentity ValidateBasicCredentials(string username, string password); + + /// + /// Validates a bearer token and returns the corresponding identity. + /// + /// The provided bearer token. + /// The authenticated identity, or null if the token is invalid. + protected abstract IIdentity ValidateToken(string token); + + /// + /// Authenticates the specified request by inspecting the HTTP Authorization header. + /// Supports both Basic and Bearer (token) authentication schemes. + /// + /// The request to authenticate. Cannot be null. + /// + /// The identity of the authenticated user if authentication succeeds; otherwise, null. + /// + public IIdentity Authenticate(IRequest request) + { + if (request is null) + { + return null; + } + + var authorization = request.Header?.Authorization; + + if (authorization is null || string.IsNullOrWhiteSpace(authorization.Type)) + { + return null; + } + + var authType = authorization.Type.ToLowerInvariant(); + + if (authType == "basic") + { + return ValidateBasicCredentials(authorization.Identification, authorization.Password); + } + + if (authType == "bearer" || authType == "token") + { + return ValidateToken(authorization.Identification); + } + + return null; + } + } +} diff --git a/src/WebExpress.WebApp/WebPage/PageWebAppForbidden.cs b/src/WebExpress.WebApp/WebPage/PageWebAppForbidden.cs new file mode 100644 index 0000000..4ff6820 --- /dev/null +++ b/src/WebExpress.WebApp/WebPage/PageWebAppForbidden.cs @@ -0,0 +1,65 @@ +using System; +using WebExpress.WebCore; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebPage; +using WebExpress.WebUI.WebControl; + +namespace WebExpress.WebApp.WebPage +{ + /// + /// Represents an access-denied page for the web application. + /// Shown when an authenticated user attempts to access a resource for which + /// they do not have sufficient permissions. + /// + public class PageWebAppForbidden : IPage + { + /// + /// Processing of the page. + /// Renders an access-denied message with a link to switch accounts or return to login. + /// + /// The context for rendering the page. + /// The visual tree control to be processed. + public void Process(IRenderContext renderContext, VisualTreeWebAppLogin visualTree) + { + if (renderContext is null) + { + throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); + } + + if (visualTree is null) + { + throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); + } + + var title = new ControlText() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:forbidden.title"), + Format = TypeFormatText.H2, + Margin = new PropertySpacingMargin(PropertySpacing.Space.Two, PropertySpacing.Space.Three) + }; + + var description = new ControlText() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:forbidden.description"), + Format = TypeFormatText.Paragraph, + Margin = new PropertySpacingMargin(PropertySpacing.Space.Two, PropertySpacing.Space.Three) + }; + + var loginUri = WebEx.ComponentHub.SitemapManager.GetUri(renderContext.PageContext?.ApplicationContext); + + var switchAccountLink = new ControlLink() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:forbidden.switchaccount.label"), + Uri = loginUri + }; + + var card = new ControlPanelCard("wx-forbidden-card", title, description, switchAccountLink) + { + Margin = new PropertySpacingMargin(PropertySpacing.Space.Three) + }; + + visualTree.Title = I18N.Translate(renderContext, "webexpress.webapp:forbidden.title"); + visualTree.Content.MainPanel.AddPrimary(card); + } + } +} diff --git a/src/WebExpress.WebApp/WebPage/PageWebAppLogin.cs b/src/WebExpress.WebApp/WebPage/PageWebAppLogin.cs new file mode 100644 index 0000000..9c7c43f --- /dev/null +++ b/src/WebExpress.WebApp/WebPage/PageWebAppLogin.cs @@ -0,0 +1,102 @@ +using System; +using System.Linq; +using System.Security; +using WebExpress.WebCore; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebPage; +using WebExpress.WebUI.WebControl; + +namespace WebExpress.WebApp.WebPage +{ + /// + /// Represents a login page for the web application. + /// Displays a username/password form and delegates authentication to the identity manager. + /// + public class PageWebAppLogin : IPage + { + private readonly ControlFormItemInputText _usernameInput = new("wx-login-username") + { + Label = "webexpress.webapp:login.username.label", + Placeholder = "webexpress.webapp:login.username.placeholder", + Required = true + }; + + // note: the framework's ControlFormItemInputText renders as a plain text field. + // password masking requires a password-capable control once available in the framework. + private readonly ControlFormItemInputText _passwordInput = new("wx-login-password") + { + Label = "webexpress.webapp:login.password.label", + Placeholder = "webexpress.webapp:login.password.placeholder", + Required = true + }; + + private readonly ControlForm _loginForm = new("wx-login-form"); + + /// + /// Processing of the page. + /// + /// The context for rendering the page. + /// The visual tree control to be processed. + public void Process(IRenderContext renderContext, VisualTreeWebAppLogin visualTree) + { + if (renderContext is null) + { + throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); + } + + if (visualTree is null) + { + throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); + } + + var title = new ControlText() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:login.title"), + Format = TypeFormatText.H2, + Margin = new PropertySpacingMargin(PropertySpacing.Space.Two, PropertySpacing.Space.Three) + }; + + _loginForm + .Add(_usernameInput, _passwordInput) + .AddPrimaryButton(new ControlFormItemButtonSubmit() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:login.submit.label"), + Color = new PropertyColorButton(TypeColorButton.Primary) + }) + .Validate(e => + { + var username = e.Context.GetValue(_usernameInput)?.Text; + var password = e.Context.GetValue(_passwordInput)?.Text; + + e.Add(string.IsNullOrWhiteSpace(username), "webexpress.webapp:login.error.empty", TypeInputValidity.Error); + e.Add(string.IsNullOrWhiteSpace(password), "webexpress.webapp:login.error.empty", TypeInputValidity.Error); + }) + .Process(e => + { + var username = e.GetValue(_usernameInput)?.Text; + var password = e.GetValue(_passwordInput)?.Text; + + // note: converting a plain string to SecureString is required by the IdentityManager.Login + // API even though the password is already in memory as a plain string at this point. + var securePassword = new SecureString(); + foreach (var ch in password ?? string.Empty) + { + securePassword.AppendChar(ch); + } + + var identity = WebEx.ComponentHub.IdentityManager.Identities + .FirstOrDefault(x => string.Equals(x.Name, username, StringComparison.OrdinalIgnoreCase)); + + WebEx.ComponentHub.IdentityManager.Login(renderContext.Request, identity, securePassword); + }); + + var card = new ControlPanelCard("wx-login-card", title, _loginForm) + { + Margin = new PropertySpacingMargin(PropertySpacing.Space.Three) + }; + + visualTree.Title = I18N.Translate(renderContext, "webexpress.webapp:login.title"); + visualTree.Content.MainPanel.AddPrimary(card); + } + } +} diff --git a/src/WebExpress.WebApp/WebPage/PageWebAppLogout.cs b/src/WebExpress.WebApp/WebPage/PageWebAppLogout.cs new file mode 100644 index 0000000..af207c5 --- /dev/null +++ b/src/WebExpress.WebApp/WebPage/PageWebAppLogout.cs @@ -0,0 +1,68 @@ +using System; +using WebExpress.WebCore; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebPage; +using WebExpress.WebUI.WebControl; + +namespace WebExpress.WebApp.WebPage +{ + /// + /// Represents a logout page for the web application. + /// Terminates the current authenticated session and presents a confirmation message + /// with a navigation link back to the login page. + /// + public class PageWebAppLogout : IPage + { + /// + /// Processing of the page. + /// Performs the logout operation and renders a confirmation message. + /// + /// The context for rendering the page. + /// The visual tree control to be processed. + public void Process(IRenderContext renderContext, VisualTreeWebAppLogin visualTree) + { + if (renderContext is null) + { + throw new ArgumentNullException(nameof(renderContext), "Parameter cannot be null or empty."); + } + + if (visualTree is null) + { + throw new ArgumentNullException(nameof(visualTree), "Parameter cannot be null or empty."); + } + + // terminate the authenticated session + WebEx.ComponentHub.IdentityManager.Logout(renderContext.Request); + + var title = new ControlText() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:logout.title"), + Format = TypeFormatText.H2, + Margin = new PropertySpacingMargin(PropertySpacing.Space.Two, PropertySpacing.Space.Three) + }; + + var message = new ControlText() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:logout.description"), + Format = TypeFormatText.Paragraph, + Margin = new PropertySpacingMargin(PropertySpacing.Space.Two, PropertySpacing.Space.Three) + }; + + var loginUri = WebEx.ComponentHub.SitemapManager.GetUri(renderContext.PageContext?.ApplicationContext); + + var loginLink = new ControlLink() + { + Text = I18N.Translate(renderContext, "webexpress.webapp:logout.login.label"), + Uri = loginUri + }; + + var card = new ControlPanelCard("wx-logout-card", title, message, loginLink) + { + Margin = new PropertySpacingMargin(PropertySpacing.Space.Three) + }; + + visualTree.Title = I18N.Translate(renderContext, "webexpress.webapp:logout.title"); + visualTree.Content.MainPanel.AddPrimary(card); + } + } +} diff --git a/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs b/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs new file mode 100644 index 0000000..0ad4d7b --- /dev/null +++ b/src/WebExpress.WebApp/WebPage/VisualTreeWebAppLogin.cs @@ -0,0 +1,137 @@ +using System.Linq; +using WebExpress.WebApp.WebControl; +using WebExpress.WebApp.WebPage; +using WebExpress.WebCore; +using WebExpress.WebCore.Internationalization; +using WebExpress.WebCore.WebComponent; +using WebExpress.WebCore.WebEndpoint; +using WebExpress.WebCore.WebHtml; +using WebExpress.WebCore.WebPage; +using WebExpress.WebCore.WebTheme; +using WebExpress.WebUI.WebControl; +using WebExpress.WebUI.WebPage; + +namespace WebExpress.WebApp.WebPage +{ + /// + /// Represents a simplified visual tree used for login, logout, and access-denied pages. + /// The layout omits the sidebar and breadcrumb, and centers the content within the page. + /// + public class VisualTreeWebAppLogin : VisualTreeControl, IVisualTreeWebApp + { + /// + /// Returns or sets the theme of the web application. + /// + public IThemeContext Theme { get; set; } + + /// + /// Returns header control. + /// + public ControlWebAppHeader Header { get; } = new ControlWebAppHeader("wx-header"); + + /// + /// Returns the area for the toast messages control. + /// + public ControlWebAppToastnotification Toast { get; protected set; } = new ControlWebAppToastnotification("wx-toast"); + + /// + /// Returns the range for the path specification. + /// Not rendered in the login visual tree. + /// + public ControlBreadcrumb Breadcrumb { get; protected set; } = new ControlBreadcrumb("wx-breadcrumb"); + + /// + /// Returns the area for prologue. + /// Not rendered in the login visual tree. + /// + public ControlWebAppPrologue Prologue { get; protected set; } = new ControlWebAppPrologue("wx-prologue"); + + /// + /// Returns the sidebar control. + /// Not rendered in the login visual tree. + /// + public IControlWebAppSidebar Sidebar { get; protected set; } = new ControlWebAppSidebar("wx-sidebar"); + + /// + /// Returns the content control. + /// + public new IControlWebAppContent Content { get; protected set; } = new ControlWebAppContent("wx-content"); + + /// + /// Returns the footer control. + /// + public IControlWebAppFooter Footer { get; protected set; } = new ControlWebAppFooter("wx-footer"); + + /// + /// Returns the control for displaying notification popups via API. + /// + public ControlRestPopupNotification NotificationPopup { get; protected set; } = new ControlRestPopupNotification("wx-notificationpopup"); + + /// + /// Returns or sets a delegate that returns the collection of domain names associated with + /// the current context. Not used in the login visual tree. + /// + public System.Func> Domains { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The component hub. + /// The page context. + public VisualTreeWebAppLogin(IComponentHub componentHub, IPageContext pageContext) + : base(componentHub, pageContext) + { + var applicationContext = pageContext?.ApplicationContext; + var baseUri = RouteEndpoint.Combine(applicationContext?.Route, "webexpress.webapp/assets"); + + Header.Fixed = TypeFixed.Top; + Header.Styles = ["position: sticky; top: 0; z-index: 99;"]; + + AddCssLink(Theme?.ThemeStyle.ToString() ?? RouteEndpoint.Combine(baseUri, "css/webexpress.webapp.theme.css")); + } + + /// + /// Convert to html. + /// + /// The context for rendering the page. + /// The page as an html tree. + public override IHtmlNode Render(IVisualTreeContext context) + { + var html = new HtmlElementRootHtml(); + var renderContext = new RenderControlContext(context.RenderContext); + + // head + html.Head.Title = I18N.Translate(context.Request, Title); + html.Head.Favicons = Favicons; + html.Head.Styles = Styles; + html.Head.Meta = Meta; + html.Head.Scripts = HeaderScripts; + html.Head.CssLinks = CssLinks.Where(x => x is not null).Select(x => x.ToString()); + html.Head.ScriptLinks = HeaderScriptLinks?.Where(x => x is not null).Select(x => x.ToString()); + + // body + Header.AppTitle.SetTitle(html.Head.Title); + if (Theme?.ThemeMode == ThemeMode.Dark) + { + html.Body.AddUserAttribute("data-bs-theme", "dark"); + } + + html.Body.Add(Header.Render(renderContext, this)); + html.Body.Add(Toast.Render(renderContext, this)); + + // render centered content area without sidebar or breadcrumb + var centeredContent = new ControlPanelCenter("wx-login-center", + Content.MainPanel as IControl) + { + Fluid = TypePanelContainer.Default + }; + + html.Body.Add(centeredContent.Render(renderContext, this)); + html.Body.Add(NotificationPopup.Render(renderContext, this)); + + html.Body.Scripts = [.. Scripts.Values]; + + return html; + } + } +} diff --git a/src/WebExpress.WebApp/WebStatusPage/PageStatusWebAppForbidden.cs b/src/WebExpress.WebApp/WebStatusPage/PageStatusWebAppForbidden.cs new file mode 100644 index 0000000..3e4778b --- /dev/null +++ b/src/WebExpress.WebApp/WebStatusPage/PageStatusWebAppForbidden.cs @@ -0,0 +1,25 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebStatusPage; + +namespace WebExpress.WebApp.WebStatusPage +{ + /// + /// Represents the HTTP 403 Forbidden status page for the web application. + /// Displayed when an authenticated user attempts to access a resource they are not permitted to access. + /// + [Title("webexpress.webapp:status.403.title")] + [Description("webexpress.webapp:status.403.description")] + [StatusResponse()] + public sealed class PageStatusWebAppForbidden : PageStatusWebApp + { + /// + /// Initializes a new instance of the class. + /// + /// The context of the status page. + private PageStatusWebAppForbidden(IStatusPageContext statusPageContext) + : base(statusPageContext) + { + } + } +} diff --git a/src/WebExpress.WebApp/WebStatusPage/PageStatusWebAppUnauthorized.cs b/src/WebExpress.WebApp/WebStatusPage/PageStatusWebAppUnauthorized.cs new file mode 100644 index 0000000..53052b7 --- /dev/null +++ b/src/WebExpress.WebApp/WebStatusPage/PageStatusWebAppUnauthorized.cs @@ -0,0 +1,25 @@ +using WebExpress.WebCore.WebAttribute; +using WebExpress.WebCore.WebMessage; +using WebExpress.WebCore.WebStatusPage; + +namespace WebExpress.WebApp.WebStatusPage +{ + /// + /// Represents the HTTP 401 Unauthorized status page for the web application. + /// Displayed when a request requires authentication that has not been provided or is invalid. + /// + [Title("webexpress.webapp:status.401.title")] + [Description("webexpress.webapp:status.401.description")] + [StatusResponse()] + public sealed class PageStatusWebAppUnauthorized : PageStatusWebApp + { + /// + /// Initializes a new instance of the class. + /// + /// The context of the status page. + private PageStatusWebAppUnauthorized(IStatusPageContext statusPageContext) + : base(statusPageContext) + { + } + } +}