diff --git a/Directory.Build.props b/Directory.Build.props index aff5bd3e..28379aa7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,7 +2,7 @@ net10.0 Debug;Release - 10.0.0 + 10.0.1 Oqtane Shaun Walker .NET Foundation @@ -10,7 +10,7 @@ .NET Foundation https://www.oqtane.org https://github.com/oqtane/oqtane.framework/blob/dev/LICENSE - https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0 + https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1 https://github.com/oqtane/oqtane.framework Git diff --git a/Oqtane.Application/Client/Oqtane.Application.Client.csproj b/Oqtane.Application/Client/Oqtane.Application.Client.csproj index fdbfd650..d932bbb6 100644 --- a/Oqtane.Application/Client/Oqtane.Application.Client.csproj +++ b/Oqtane.Application/Client/Oqtane.Application.Client.csproj @@ -12,10 +12,10 @@ - - - - + + + + @@ -23,7 +23,7 @@ - + diff --git a/Oqtane.Application/Oqtane.Application.Template.nuspec b/Oqtane.Application/Oqtane.Application.Template.nuspec index e7097801..1b9a140b 100644 --- a/Oqtane.Application/Oqtane.Application.Template.nuspec +++ b/Oqtane.Application/Oqtane.Application.Template.nuspec @@ -2,7 +2,7 @@ Oqtane.Application.Template - 10.0.0 + 10.0.1 Oqtane Application Template For Blazor Shaun Walker false diff --git a/Oqtane.Application/Server/Oqtane.Application.Server.csproj b/Oqtane.Application/Server/Oqtane.Application.Server.csproj index 4d6ad43b..c1ed4362 100644 --- a/Oqtane.Application/Server/Oqtane.Application.Server.csproj +++ b/Oqtane.Application/Server/Oqtane.Application.Server.csproj @@ -22,9 +22,9 @@ - - - + + + @@ -33,7 +33,7 @@ - + diff --git a/Oqtane.Application/Shared/Oqtane.Application.Shared.csproj b/Oqtane.Application/Shared/Oqtane.Application.Shared.csproj index bc957313..a063b58e 100644 --- a/Oqtane.Application/Shared/Oqtane.Application.Shared.csproj +++ b/Oqtane.Application/Shared/Oqtane.Application.Shared.csproj @@ -11,7 +11,7 @@ - + diff --git a/Oqtane.Client/Modules/Admin/Dashboard/Index.razor b/Oqtane.Client/Modules/Admin/Dashboard/Index.razor index 9ca81674..c04f97e8 100644 --- a/Oqtane.Client/Modules/Admin/Dashboard/Index.razor +++ b/Oqtane.Client/Modules/Admin/Dashboard/Index.razor @@ -13,7 +13,7 @@ { string url = NavigateUrl(p.Path);
- +

@((MarkupString)SharedLocalizer[p.Name].ToString().Replace(" ", "
"))
@@ -24,13 +24,19 @@ } @code { - private List _pages; + Dictionary _attributes { get; set; } = new(); + private List _pages; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.View; public override string RenderMode => RenderModes.Static; protected override void OnInitialized() { + if (PageState.RenderMode == RenderModes.Static && !PageState.Site.EnhancedNavigation) + { + _attributes.Add("data-enhance-nav", "true"); // Admin Dashboard utilizes enhanced navigation + } + var admin = PageState.Pages.FirstOrDefault(item => item.Path == "admin"); if (admin != null) { diff --git a/Oqtane.Client/Modules/Admin/Login/Index.razor b/Oqtane.Client/Modules/Admin/Login/Index.razor index 83d49fd9..4a6fee49 100644 --- a/Oqtane.Client/Modules/Admin/Login/Index.razor +++ b/Oqtane.Client/Modules/Admin/Login/Index.razor @@ -14,93 +14,133 @@ } else { - @if (!twofactor) - { -
-
+ @if (_rendermode == RenderModes.Static) + { +
+ +
+ +
+
+ }
@@ -537,6 +549,7 @@ private string _defaultalias; private string _rendermode = RenderModes.Interactive; + private string _enhancednavigation = "True"; private string _runtime = Runtimes.Server; private string _prerender = "True"; private string _hybrid = "False"; @@ -660,6 +673,7 @@ // hosting model _rendermode = site.RenderMode; + _enhancednavigation = site.EnhancedNavigation.ToString(); _runtime = site.Runtime; _prerender = site.Prerender.ToString(); _hybrid = site.Hybrid.ToString(); @@ -669,7 +683,7 @@ { var tenants = await TenantService.GetTenantsAsync(); var _databases = await DatabaseService.GetDatabasesAsync(); - var tenant = tenants.Find(item => item.TenantId == site.TenantId); + var tenant = tenants.Find(item => item.TenantId == PageState.Alias.TenantId); if (tenant != null) { _tenant = tenant.Name; @@ -807,13 +821,11 @@ // hosting model if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { - if (site.RenderMode != _rendermode || site.Runtime != _runtime || site.Prerender != bool.Parse(_prerender) || site.Hybrid != bool.Parse(_hybrid)) - { - site.RenderMode = _rendermode; - site.Runtime = _runtime; - site.Prerender = bool.Parse(_prerender); - site.Hybrid = bool.Parse(_hybrid); - } + site.RenderMode = _rendermode; + site.EnhancedNavigation = bool.Parse(_enhancednavigation); + site.Runtime = _runtime; + site.Prerender = bool.Parse(_prerender); + site.Hybrid = bool.Parse(_hybrid); } site = await SiteService.UpdateSiteAsync(site); @@ -874,17 +886,17 @@ try { var aliases = await AliasService.GetAliasesAsync(); - if (aliases.Any(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Site.TenantId)) + if (aliases.Any(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Alias.TenantId)) { await SiteService.DeleteSiteAsync(PageState.Site.SiteId); await logger.LogInformation("Site Deleted {SiteId}", PageState.Site.SiteId); - foreach (Alias alias in aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Site.TenantId)) + foreach (Alias alias in aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Alias.TenantId)) { await AliasService.DeleteAliasAsync(alias.AliasId); } - var redirect = aliases.First(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Site.TenantId); + var redirect = aliases.First(item => item.SiteId != PageState.Site.SiteId || item.TenantId != PageState.Alias.TenantId); NavigationManager.NavigateTo(PageState.Uri.Scheme + "://" + redirect.Name, true); } else @@ -981,7 +993,7 @@ if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { _aliases = await AliasService.GetAliasesAsync(); - _aliases = _aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Site.TenantId).OrderBy(item => item.AliasId).ToList(); + _aliases = _aliases.Where(item => item.SiteId == PageState.Site.SiteId && item.TenantId == PageState.Alias.TenantId).OrderBy(item => item.AliasId).ToList(); } } @@ -1034,7 +1046,7 @@ { if (_aliasid == 0) { - alias = new Alias { SiteId = PageState.Site.SiteId, TenantId = PageState.Site.TenantId, Name = _aliasname, IsDefault = bool.Parse(_defaultalias) }; + alias = new Alias { SiteId = PageState.Site.SiteId, TenantId = PageState.Alias.TenantId, Name = _aliasname, IsDefault = bool.Parse(_defaultalias) }; await AliasService.AddAliasAsync(alias); } else diff --git a/Oqtane.Client/Modules/Admin/SystemInfo/Index.razor b/Oqtane.Client/Modules/Admin/SystemInfo/Index.razor index 6f7c4b02..c64070ff 100644 --- a/Oqtane.Client/Modules/Admin/SystemInfo/Index.razor +++ b/Oqtane.Client/Modules/Admin/SystemInfo/Index.razor @@ -271,7 +271,7 @@ } var tenants = await TenantService.GetTenantsAsync(); - _tenant = tenants.Find(item => item.TenantId == PageState.Site.TenantId).Name; + _tenant = tenants.Find(item => item.TenantId == PageState.Alias.TenantId).Name; _history = await MigrationHistoryService.GetMigrationHistoryAsync(); _initialized = true; diff --git a/Oqtane.Client/Modules/Admin/Themes/Create.razor b/Oqtane.Client/Modules/Admin/Themes/Create.razor index f10ff2bf..9006dfd0 100644 --- a/Oqtane.Client/Modules/Admin/Themes/Create.razor +++ b/Oqtane.Client/Modules/Admin/Themes/Create.razor @@ -1,6 +1,7 @@ @namespace Oqtane.Modules.Admin.Themes @inherits ModuleBase @using System.Text.RegularExpressions +@using System.Reflection @inject NavigationManager NavigationManager @inject IThemeService ThemeService @inject IModuleService ModuleService @@ -88,6 +89,16 @@ { AddModuleMessage(Localizer["Info.Theme.CreatorIntent"], MessageType.Info); } + else + { + var entryAssemblyName = Assembly.GetEntryAssembly().GetName().Name; + if (entryAssemblyName.EndsWith(".Oqtane")) + { + // Oqtane Application assemblies end with .Server.Oqtane or .Client.Oqtane + string[] segments = entryAssemblyName.Split('.'); + _owner = string.Join(".", segments, 0, segments.Length - 2); + } + } } protected override async Task OnParametersSetAsync() diff --git a/Oqtane.Client/Modules/Admin/Users/Index.razor b/Oqtane.Client/Modules/Admin/Users/Index.razor index edb519c2..4a796298 100644 --- a/Oqtane.Client/Modules/Admin/Users/Index.razor +++ b/Oqtane.Client/Modules/Admin/Users/Index.razor @@ -33,7 +33,7 @@ else

- +
    @@ -60,9 +60,46 @@ else - +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
@@ -72,466 +109,432 @@ else
- @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) + @if (_allowregistration == "true") { - @if (_allowregistration == "true") - { -
- -
- -
-
- }
- +
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- +
} +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
- @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) - { -
-
- -
- -
-
-
- -
- -
-
-
- -
- +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
+
+ + @if (!string.IsNullOrEmpty(_providerurl)) + { + @Localizer["Info"] + }
-
+ +
+
+
+ +
+ +
+
+ @if (_providertype != "") + {
- +
- +
-
+
+ } + @if (_providertype == AuthenticationProviderTypes.OpenIDConnect) + {
- +
- +
- +
- +
-
- -
+ + } + @if (_providertype == AuthenticationProviderTypes.OAuth2) + {
- +
- +
-
+
- +
- +
-
-
-
+
- + +
+ +
+
+ } + @if (_providertype != "") + { +
+ +
+ +
+
+
+
- - @if (!string.IsNullOrEmpty(_providerurl)) - { - @Localizer["Info"] - } + +
-
-
- -
- -
-
- @if (_providertype != "") - { -
- -
- -
-
- } @if (_providertype == AuthenticationProviderTypes.OpenIDConnect) {
- +
- +
- +
- + +
+
+
+ +
+
} - @if (_providertype == AuthenticationProviderTypes.OAuth2) - { -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- } - @if (_providertype != "") - { -
- -
- -
-
-
- -
-
- - -
-
-
- @if (_providertype == AuthenticationProviderTypes.OpenIDConnect) - { -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- } -
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
-
- - @if (_reviewclaims == "true") - { - @SharedLocalizer["Test"] - } -
-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
-
- -
-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
- @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) - { -
- -
- -
-
-
- -
- -
-
- } - } -
-
- + +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
- - + + @if (_reviewclaims == "true") + { + @SharedLocalizer["Test"] + }
-
+
- +
- +
-
+
- +
- +
-
+
- +
- +
-
+
- + +
+ +
+
+
+ +
+ +
+
+
+
- - +
-
-
- } + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) + { +
+ +
+ +
+
+ } + } + +
+
+ +
+
+ + +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+ + +
+
+
+

@@ -542,13 +545,16 @@ else @code { private List users; private string _deleted = "false"; + private int _page = 1; + private string _allowsitelogin; + private string _twofactor; + private string _loginlink; + private string _passkeys; private string _allowregistration; private string _registerurl; - private string _profileurl; private string _requireconfirmedemail; - private string _passkeys; - private string _twofactor; + private string _profileurl; private string _cookiename; private string _cookiedomain; private string _cookieexpiration; @@ -598,7 +604,6 @@ else private string _createusers; private string _verifyusers; private string _allowhostrole; - private string _allowsitelogin; private string _secret; private string _secrettype = "password"; @@ -622,11 +627,13 @@ else if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { - _registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", ""); - _profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", ""); - _requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true"); - _passkeys = SettingService.GetSetting(settings, "LoginOptions:Passkeys", "false"); + _allowsitelogin = SettingService.GetSetting(settings, "LoginOptions:AllowSiteLogin", "true"); _twofactor = SettingService.GetSetting(settings, "LoginOptions:TwoFactor", "false"); + _loginlink = SettingService.GetSetting(settings, "LoginOptions:LoginLink", "false"); + _passkeys = SettingService.GetSetting(settings, "LoginOptions:Passkeys", "false"); + _registerurl = SettingService.GetSetting(settings, "LoginOptions:RegisterUrl", ""); + _requireconfirmedemail = SettingService.GetSetting(settings, "LoginOptions:RequireConfirmedEmail", "true"); + _profileurl = SettingService.GetSetting(settings, "LoginOptions:ProfileUrl", ""); _cookiename = SettingService.GetSetting(settings, "LoginOptions:CookieName", ".AspNetCore.Identity.Application"); _cookiedomain = SettingService.GetSetting(settings, "LoginOptions:CookieDomain", ""); _cookieexpiration = SettingService.GetSetting(settings, "LoginOptions:CookieExpiration", ""); @@ -688,7 +695,6 @@ else _createusers = SettingService.GetSetting(settings, "ExternalLogin:CreateUsers", "true"); _verifyusers = SettingService.GetSetting(settings, "ExternalLogin:VerifyUsers", "true"); _allowhostrole = SettingService.GetSetting(settings, "ExternalLogin:AllowHostRole", "false"); - _allowsitelogin = SettingService.GetSetting(settings, "LoginOptions:AllowSiteLogin", "true"); } private async Task LoadUsersAsync() @@ -753,19 +759,21 @@ else { try { - var site = PageState.Site; - site.AllowRegistration = bool.Parse(_allowregistration); - await SiteService.UpdateSiteAsync(site); - - var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); - if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) { - settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false); - settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false); - settings = SettingService.SetSetting(settings, "LoginOptions:RequireConfirmedEmail", _requireconfirmedemail, false); - settings = SettingService.SetSetting(settings, "LoginOptions:Passkeys", _passkeys, false); + var site = PageState.Site; + site.AllowRegistration = bool.Parse(_allowregistration); + await SiteService.UpdateSiteAsync(site); + + var settings = await SettingService.GetSiteSettingsAsync(site.SiteId); + + settings = SettingService.SetSetting(settings, "LoginOptions:AllowSiteLogin", _allowsitelogin, false); settings = SettingService.SetSetting(settings, "LoginOptions:TwoFactor", _twofactor, false); + settings = SettingService.SetSetting(settings, "LoginOptions:LoginLink", _loginlink, false); + settings = SettingService.SetSetting(settings, "LoginOptions:Passkeys", _passkeys, false); + settings = SettingService.SetSetting(settings, "LoginOptions:RegisterUrl", _registerurl, false); + settings = SettingService.SetSetting(settings, "LoginOptions:RequireConfirmedEmail", _requireconfirmedemail, false); + settings = SettingService.SetSetting(settings, "LoginOptions:ProfileUrl", _profileurl, false); settings = SettingService.SetSetting(settings, "LoginOptions:CookieName", _cookiename, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieDomain", _cookiedomain, true); settings = SettingService.SetSetting(settings, "LoginOptions:CookieExpiration", _cookieexpiration, true); @@ -811,16 +819,15 @@ else settings = SettingService.SetSetting(settings, "ExternalLogin:CreateUsers", _createusers, true); settings = SettingService.SetSetting(settings, "ExternalLogin:VerifyUsers", _verifyusers, true); settings = SettingService.SetSetting(settings, "ExternalLogin:AllowHostRole", _allowhostrole, true); - settings = SettingService.SetSetting(settings, "LoginOptions:AllowSiteLogin", _allowsitelogin, false); settings = SettingService.SetSetting(settings, "JwtOptions:Secret", _secret, true); settings = SettingService.SetSetting(settings, "JwtOptions:Issuer", _issuer, true); settings = SettingService.SetSetting(settings, "JwtOptions:Audience", _audience, true); settings = SettingService.SetSetting(settings, "JwtOptions:Lifetime", _lifetime, true); - } - await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId); - await SettingService.ClearSiteSettingsCacheAsync(); + await SettingService.UpdateSiteSettingsAsync(settings, site.SiteId); + await SettingService.ClearSiteSettingsCacheAsync(); + } if (!string.IsNullOrEmpty(_secret)) { diff --git a/Oqtane.Client/Modules/Controls/ActionDialog.razor b/Oqtane.Client/Modules/Controls/ActionDialog.razor index 83948efe..11204bc7 100644 --- a/Oqtane.Client/Modules/Controls/ActionDialog.razor +++ b/Oqtane.Client/Modules/Controls/ActionDialog.razor @@ -35,11 +35,11 @@ { if (Disabled) { - + } else { - + } } } @@ -83,13 +83,13 @@ else { if (Disabled) { - + } else {
- +
} } @@ -112,6 +112,9 @@ else [Parameter] public string Text { get; set; } // optional - defaults to Action if not specified + + [Parameter] + public string AltText { get; set; } // optional [Parameter] public string Action { get; set; } // optional diff --git a/Oqtane.Client/Modules/Controls/ActionLink.razor b/Oqtane.Client/Modules/Controls/ActionLink.razor index 545dca5c..576a040f 100644 --- a/Oqtane.Client/Modules/Controls/ActionLink.razor +++ b/Oqtane.Client/Modules/Controls/ActionLink.razor @@ -8,17 +8,17 @@ { if (Disabled) { - @((MarkupString)_iconSpan) @_text + @((MarkupString)_iconSpan) @_text } else { if (OnClick == null) { - @((MarkupString)_iconSpan) @_text + @((MarkupString)_iconSpan) @_text } else { - + } } } @@ -42,6 +42,9 @@ [Parameter] public string Text { get; set; } // optional - defaults to Action if not specified + [Parameter] + public string AltText { get; set; } // optional + [Parameter] public int ModuleId { get; set; } = -1; // optional - allows the link to target a specific moduleid diff --git a/Oqtane.Client/Modules/Controls/RichTextEditor.razor b/Oqtane.Client/Modules/Controls/RichTextEditor.razor index 6933783a..e4e98fba 100644 --- a/Oqtane.Client/Modules/Controls/RichTextEditor.razor +++ b/Oqtane.Client/Modules/Controls/RichTextEditor.razor @@ -7,7 +7,7 @@ @inject ISettingService SettingService @inject IStringLocalizer Localizer -
+
@_textEditorComponent
@@ -18,6 +18,8 @@ private RenderFragment _textEditorComponent; private ITextEditor _textEditor; + private string _style = "margin-bottom: 50px;"; + [Parameter] public string Content { get; set; } @@ -30,6 +32,9 @@ [Parameter] public string Provider { get; set; } + [Parameter] + public string Style { get; set; } // optional + [Parameter(CaptureUnmatchedValues = true)] public Dictionary AdditionalAttributes { get; set; } = new Dictionary(); @@ -40,6 +45,12 @@ protected override void OnParametersSet() { + + if (!string.IsNullOrEmpty(Style)) + { + _style = Style; + } + _textEditorComponent = (builder) => { CreateTextEditor(builder); diff --git a/Oqtane.Client/Modules/Controls/TabPanel.razor b/Oqtane.Client/Modules/Controls/TabPanel.razor index cff8d9e1..5ac41365 100644 --- a/Oqtane.Client/Modules/Controls/TabPanel.razor +++ b/Oqtane.Client/Modules/Controls/TabPanel.razor @@ -30,6 +30,12 @@ else [Parameter] public SecurityAccessLevel? Security { get; set; } // optional - can be used to specify SecurityAccessLevel + [Parameter] + public string RoleName { get; set; } // optional - can be used to specify Role allowed to view this tab + + [Parameter] + public string PermissionName { get; set; } // optional - can be used to specify Permission allowed to view this tab + protected override void OnParametersSet() { base.OnParametersSet(); diff --git a/Oqtane.Client/Modules/Controls/TabStrip.razor b/Oqtane.Client/Modules/Controls/TabStrip.razor index e2a3c0f1..a8402d86 100644 --- a/Oqtane.Client/Modules/Controls/TabStrip.razor +++ b/Oqtane.Client/Modules/Controls/TabStrip.razor @@ -84,12 +84,37 @@ } } + /// + /// Determines if a tab should be visible based on user permissions. + /// Authorization hierarchy: + /// 1. Host and Admin roles ALWAYS have access (bypass all checks) + /// 2. Check standard SecurityAccessLevel (View, Edit, etc.) + /// 3. If RoleName specified AND user is not Admin/Host, check RoleName + /// 4. If PermissionName specified AND user is not Admin/Host, check PermissionName + /// + /// The tab panel to check authorization for + /// True if user is authorized to see this tab, false otherwise private bool IsAuthorized(TabPanel tabPanel) { + // Step 1: Check for Host-only restriction + if (tabPanel.Security == SecurityAccessLevel.Host) + { + // Only Host users can access Host-level security tabs (Admin users are excluded) + return UserSecurity.IsAuthorized(PageState.User, RoleNames.Host); + } + + // Step 2: Admin bypass all other restrictions + if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin)) + { + return true; + } + var authorized = false; + + // Step 3: Check standard SecurityAccessLevel switch (tabPanel.Security) { - case null: // security not specified - assume SecurityAccessLevel.Anonymous + case null: authorized = true; break; case SecurityAccessLevel.Anonymous: @@ -101,13 +126,23 @@ case SecurityAccessLevel.Edit: authorized = UserSecurity.IsAuthorized(PageState.User, PermissionNames.Edit, ModuleState.PermissionList); break; - case SecurityAccessLevel.Admin: - authorized = UserSecurity.IsAuthorized(PageState.User, RoleNames.Admin); - break; case SecurityAccessLevel.Host: authorized = UserSecurity.IsAuthorized(PageState.User, RoleNames.Host); break; } + + // Step 4: Check RoleName if provided (additional requirement) + if (authorized && !string.IsNullOrEmpty(tabPanel.RoleName)) + { + authorized = UserSecurity.IsAuthorized(PageState.User, tabPanel.RoleName); + } + + // Step 5: Check PermissionName if provided (additional requirement) + if (authorized && !string.IsNullOrEmpty(tabPanel.PermissionName)) + { + authorized = UserSecurity.IsAuthorized(PageState.User, tabPanel.PermissionName, ModuleState.PermissionList); + } + return authorized; } } diff --git a/Oqtane.Client/Modules/Controls/TextEditors/Radzen/RadzenTextEditorDefinitions.cs b/Oqtane.Client/Modules/Controls/TextEditors/Radzen/RadzenTextEditorDefinitions.cs index 2326bae7..0f483562 100644 --- a/Oqtane.Client/Modules/Controls/TextEditors/Radzen/RadzenTextEditorDefinitions.cs +++ b/Oqtane.Client/Modules/Controls/TextEditors/Radzen/RadzenTextEditorDefinitions.cs @@ -31,6 +31,7 @@ namespace Oqtane.Modules.Controls { "FormatBlock", (builder, sequence) => CreateFragment(builder, sequence, "FormatBlock", "RadzenHtmlEditorFormatBlock") }, { "Indent", (builder, sequence) => CreateFragment(builder, sequence, "Indent", "RadzenHtmlEditorIndent") }, { "InsertImage", (builder, sequence) => CreateFragment(builder, sequence, "InsertImage", "RadzenHtmlEditorCustomTool", "InsertImage", "image") }, + { "Bold", (builder, sequence) => CreateFragment(builder, sequence, "Italic", "RadzenHtmlEditorBold") }, { "Italic", (builder, sequence) => CreateFragment(builder, sequence, "Italic", "RadzenHtmlEditorItalic") }, { "Justify", (builder, sequence) => CreateFragment(builder, sequence, "Justify", "RadzenHtmlEditorJustify") }, { "Link", (builder, sequence) => CreateFragment(builder, sequence, "InsertLink", "RadzenHtmlEditorCustomTool", "InsertLink", "insert_link") }, diff --git a/Oqtane.Client/Modules/ModuleBase.cs b/Oqtane.Client/Modules/ModuleBase.cs index 00dc2c61..43851818 100644 --- a/Oqtane.Client/Modules/ModuleBase.cs +++ b/Oqtane.Client/Modules/ModuleBase.cs @@ -372,6 +372,11 @@ namespace Oqtane.Modules } // UI methods + private static readonly string RenderModeBoundaryErrorMessage = + "RenderModeBoundary is not available. This method requires a RenderModeBoundary parameter. " + + "If you are using child components, ensure you pass the RenderModeBoundary property to the child component: " + + ""; + public void AddModuleMessage(string message, MessageType type) { AddModuleMessage(message, type, "top"); @@ -389,21 +394,37 @@ namespace Oqtane.Modules public void AddModuleMessage(string message, MessageType type, string position, MessageStyle style) { + if (RenderModeBoundary == null) + { + throw new InvalidOperationException(RenderModeBoundaryErrorMessage); + } RenderModeBoundary.AddModuleMessage(message, type, position, style); } public void ClearModuleMessage() { + if (RenderModeBoundary == null) + { + throw new InvalidOperationException(RenderModeBoundaryErrorMessage); + } RenderModeBoundary.AddModuleMessage("", MessageType.Undefined); } public void ShowProgressIndicator() { + if (RenderModeBoundary == null) + { + throw new InvalidOperationException(RenderModeBoundaryErrorMessage); + } RenderModeBoundary.ShowProgressIndicator(); } public void HideProgressIndicator() { + if (RenderModeBoundary == null) + { + throw new InvalidOperationException(RenderModeBoundaryErrorMessage); + } RenderModeBoundary.HideProgressIndicator(); } @@ -460,6 +481,11 @@ namespace Oqtane.Modules public string ReplaceTokens(string content, object obj) { + // check for null or empty content + if (string.IsNullOrEmpty(content)) + { + return content; + } // Using StringBuilder avoids the performance penalty of repeated string allocations // that occur with string.Replace or string concatenation inside loops. var sb = new StringBuilder(); diff --git a/Oqtane.Client/Oqtane.Client.csproj b/Oqtane.Client/Oqtane.Client.csproj index bcc69ef0..aca8f7ae 100644 --- a/Oqtane.Client/Oqtane.Client.csproj +++ b/Oqtane.Client/Oqtane.Client.csproj @@ -8,11 +8,11 @@ - - - - - + + + + + diff --git a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx index 80f587e9..af2a52ba 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Login/Index.resx @@ -120,6 +120,12 @@ Forgot Password? + + Forgot Username? + + + Use Login Link + User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password. @@ -142,16 +148,19 @@ You Are Already Signed In - Please Enter The Username Related To Your Account And Then Select The Forgot Password Option Again - - Please Check The Email Address Associated To Your User Account For A Password Reset Notification + + Please Check Your Email For A Username Reminder Notification + + + A Login Link Has Been Sent To Your Email Address. The Link Is Only Valid For A Limited Amount Of Time. + - User Does Not Exist + User Does Not Exist For Criteria Specified - Please Enter The Secure Verification Code Which Was Sent To You By Email. + Please enter the secure verification code which was sent to you by email Verification Code @@ -166,7 +175,7 @@ A Secure Verification Code Has Been Sent To Your Email Address. Please Enter The Code That You Received. If You Do Not Receive The Code Or You Have Lost Access To Your Email, Please Contact Your Administrator. - Please Enter The Password Related To Your Account. Remember That Passwords Are Case Sensitive. If You Attempt Unsuccessfully To Log In To Your Account Multiple Times, You Will Be Locked Out For A Period Of Time. + Please enter the password related to your account. Remember that passwords are sase sensitive. If you attempt to login to your account multiple times unsuccessfully, you will be locked out for a period of time. Password @@ -175,13 +184,13 @@ Password: - Specify If You Would Like To Be Signed Back In Automatically The Next Time You Visit This Site + Specify if you would like to be signed back in automatically the next time you visit this site - Remember Me? + Stay Signed In? - Please Enter The Username Related To Your Account + Please enter the username related to your account Username @@ -201,7 +210,13 @@ Error Resetting Password - + + Error Sending Username Reminder + + + Error Sending Login Link + + Multiple User Accounts Already Exist With The Email Address Of Your External Login. Please Contact Your Administrator For Further Instructions. @@ -228,6 +243,9 @@ The Review Claims Option Was Enabled In External Login Settings. Please Visit The Event Log To View The Claims Returned By The Provider. + + Login Links Are Time Sensitive. Please Request Another Login Link To Complete The Login Process. + Register as new user? @@ -237,4 +255,13 @@ Passkey Login Was Not Successful + + Please enter the email address related to your account + + + Email Address + + + Email: + \ No newline at end of file diff --git a/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx b/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx index 0e212ab8..fbb57778 100644 --- a/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/RecycleBin/Index.resx @@ -204,7 +204,7 @@ Page Deleted Successfully - + All Pages Deleted Successfully diff --git a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx index 6e7fb3e0..57eecaca 100644 --- a/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx +++ b/Oqtane.Client/Resources/Modules/Admin/Users/Index.resx @@ -217,7 +217,7 @@ Unique Characters: - Do you want to allow users to sign in using a username and password that is managed locally on this site? Note that you should only disable this option if you have already sucessfully configured an external login provider, or else you may lock yourself out of the site. + Do you want to allow users to sign in using a username and password that is managed locally on this site? Note that you should only disable this option if you have already successfully configured an alternate login method, or else you may lock yourself out of the site. Allow Local Login? @@ -370,7 +370,7 @@ Do you want users to use two factor authentication? Note that you should use the Disabled option until you have successfully verified that the Notification Job in Scheduled Jobs is enabled and your SMTP options in Site Settings are configured or else you will lock yourself out. - Two Factor Authentication? + Use 2FA? Do you want to require registered users to verify their email address before they are allowed to log in? @@ -567,4 +567,10 @@ Do you want to allow users to login using passkeys (ie. passwordless authentication using WebAuthn/FIDO2)? + + Allow Login Link? + + + Do you want to allow users to login using a time sensitive link sent by email? + \ No newline at end of file diff --git a/Oqtane.Client/Resources/UI/ModuleInstance.resx b/Oqtane.Client/Resources/UI/ModuleInstance.resx index fc0994f2..4c4a4c63 100644 --- a/Oqtane.Client/Resources/UI/ModuleInstance.resx +++ b/Oqtane.Client/Resources/UI/ModuleInstance.resx @@ -124,6 +124,9 @@ Module Type Is Invalid For {0} - An Unexpected Error Has Occurred + An Unexpected Error Has Occurred + + + Missing service(s): {0}. Please make sure they have been registered correctly. \ No newline at end of file diff --git a/Oqtane.Client/Services/UserService.cs b/Oqtane.Client/Services/UserService.cs index 12685add..76de84e0 100644 --- a/Oqtane.Client/Services/UserService.cs +++ b/Oqtane.Client/Services/UserService.cs @@ -97,11 +97,18 @@ namespace Oqtane.Services Task VerifyEmailAsync(User user, string token); /// - /// Trigger a forgot-password e-mail for this . + /// Trigger a forgot-password e-mail. /// - /// + /// /// - Task ForgotPasswordAsync(User user); + Task ForgotPasswordAsync(string username); + + /// + /// Trigger a username reminder e-mail. + /// + /// + /// + Task ForgotUsernameAsync(string email); /// /// Reset the password of this @@ -211,6 +218,13 @@ namespace Oqtane.Services /// /// Task DeleteLoginAsync(int userId, string provider, string key); + + /// + /// Send a login link + /// + /// + /// + Task SendLoginLinkAsync(string email); } [PrivateApi("Don't show in the documentation, as everything should use the Interface")] @@ -275,9 +289,14 @@ namespace Oqtane.Services return await PostJsonAsync($"{Apiurl}/verify?token={token}", user); } - public async Task ForgotPasswordAsync(User user) + public async Task ForgotPasswordAsync(string username) { - await PostJsonAsync($"{Apiurl}/forgot", user); + return await GetJsonAsync($"{Apiurl}/forgotpassword/{WebUtility.UrlEncode(username)}"); + } + + public async Task ForgotUsernameAsync(string email) + { + return await GetJsonAsync($"{Apiurl}/forgotusername/{WebUtility.UrlEncode(email)}"); } public async Task ResetPasswordAsync(User user, string token) @@ -366,5 +385,10 @@ namespace Oqtane.Services { await DeleteAsync($"{Apiurl}/login?id={userId}&provider={provider}&key={key}"); } + + public async Task SendLoginLinkAsync(string email) + { + return await GetJsonAsync($"{Apiurl}/loginlink/{WebUtility.UrlEncode(email)}"); + } } } diff --git a/Oqtane.Client/UI/RenderModeBoundary.razor b/Oqtane.Client/UI/RenderModeBoundary.razor index abf96087..b6b5e0af 100644 --- a/Oqtane.Client/UI/RenderModeBoundary.razor +++ b/Oqtane.Client/UI/RenderModeBoundary.razor @@ -1,7 +1,11 @@ @namespace Oqtane.UI +@using System.Reflection +@using Module = Oqtane.Models.Module +@inject IServiceProvider ServiceProvider @inject SiteState ComponentSiteState @inject IStringLocalizer Localizer @inject ILogService LoggingService +@inject NavigationManager NavigationManager @inherits ErrorBoundary @@ -67,37 +71,50 @@ { if (ShouldRender()) { - if (!string.IsNullOrEmpty(ModuleState.ModuleType)) - { - ModuleType = Type.GetType(ModuleState.ModuleType); - if (ModuleType != null) - { - // repopulate the SiteState service based on the values passed in the SiteState parameter (this is how state is marshalled across the render mode boundary) - ComponentSiteState.Hydrate(SiteState); - - DynamicComponent = builder => - { - builder.OpenComponent(0, ModuleType); - builder.AddAttribute(1, "RenderModeBoundary", this); - builder.CloseComponent(); - }; - } - else - { - // module does not exist with typename specified - _messageContent = string.Format(Localizer["Error.Module.InvalidName"], Utilities.GetTypeNameLastSegment(ModuleState.ModuleType, 0)); - _messageType = MessageType.Error; - _messagePosition = "top"; - _messageStyle = MessageStyle.Alert; - } - } - else + if (string.IsNullOrEmpty(ModuleState.ModuleType)) { _messageContent = string.Format(Localizer["Error.Module.InvalidType"], ModuleState.ModuleDefinitionName); _messageType = MessageType.Error; _messagePosition = "top"; _messageStyle = MessageStyle.Alert; + + return; } + + ModuleType = Type.GetType(ModuleState.ModuleType); + var moduleName = Utilities.GetTypeNameLastSegment(ModuleState.ModuleType, 0); + if (ModuleType == null) + { + // module does not exist with typename specified + _messageContent = string.Format(Localizer["Error.Module.InvalidName"], moduleName); + _messageType = MessageType.Error; + _messagePosition = "top"; + _messageStyle = MessageStyle.Alert; + + return; + } + + //only validate the services injection in development environment + if (NavigationManager.BaseUri.Contains("localhost:") && !ValidateModuleTypeInjectedServices(ModuleType, out IList missingServices)) + { + // module type is not valid for instantiation + _messageContent = string.Format(Localizer["Error.Module.InvalidInjectedServices"], string.Join(",", missingServices)); + _messageType = MessageType.Error; + _messagePosition = "top"; + _messageStyle = MessageStyle.Alert; + + return; + } + + // repopulate the SiteState service based on the values passed in the SiteState parameter (this is how state is marshalled across the render mode boundary) + ComponentSiteState.Hydrate(SiteState); + + DynamicComponent = builder => + { + builder.OpenComponent(0, ModuleType); + builder.AddAttribute(1, "RenderModeBoundary", this); + builder.CloseComponent(); + }; } } @@ -165,4 +182,26 @@ _error = ""; base.Recover(); } + + private bool ValidateModuleTypeInjectedServices(Type moduleType, out IList missingServices) + { + missingServices = new List(); + + var properties = Utilities.GetPropertiesIncludingInherited(moduleType, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + foreach(var property in properties) + { + var injectAttribute = property.GetCustomAttribute(typeof(InjectAttribute)); + if (injectAttribute != null) + { + var serviceType = property.PropertyType; + var service = ServiceProvider.GetService(serviceType); + if (serviceType != null && service == null) + { + missingServices.Add(Utilities.GetTypeNameLastSegment(serviceType.FullName, 0)); + } + } + } + + return !missingServices.Any(); + } } \ No newline at end of file diff --git a/Oqtane.Client/UI/SiteRouter.razor b/Oqtane.Client/UI/SiteRouter.razor index a345a7f0..fb7d4fc3 100644 --- a/Oqtane.Client/UI/SiteRouter.razor +++ b/Oqtane.Client/UI/SiteRouter.razor @@ -158,11 +158,17 @@ // verify user is authenticated for current site var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - if (authState.User.Identity.IsAuthenticated && authState.User.Claims.Any(item => item.Type == Constants.SiteKeyClaimType && item.Value == SiteState.Alias.SiteKey)) + if (authState.User.IsAuthenticated() && authState.User.SiteKey() == SiteState.Alias.SiteKey) { - // get user - var userid = int.Parse(authState.User.Claims.First(item => item.Type == ClaimTypes.NameIdentifier).Value); - user = await UserService.GetUserAsync(userid, SiteState.Alias.SiteId); + if (PageState == null || PageState.User == null || PageState.User.UserId != authState.User.UserId()) + { + // get user + user = await UserService.GetUserAsync(authState.User.UserId(), SiteState.Alias.SiteId); + } + else + { + user = PageState.User; + } if (user != null) { user.IsAuthenticated = authState.User.Identity.IsAuthenticated; diff --git a/Oqtane.Client/_Imports.razor b/Oqtane.Client/_Imports.razor index 73af8f3a..86d19622 100644 --- a/Oqtane.Client/_Imports.razor +++ b/Oqtane.Client/_Imports.razor @@ -27,3 +27,4 @@ @using Oqtane.Enums @using Oqtane.Installer @using Oqtane.Interfaces +@using Oqtane.Extensions diff --git a/Oqtane.Maui/Oqtane.Maui.csproj b/Oqtane.Maui/Oqtane.Maui.csproj index 2d4b3572..2314ac0a 100644 --- a/Oqtane.Maui/Oqtane.Maui.csproj +++ b/Oqtane.Maui/Oqtane.Maui.csproj @@ -18,7 +18,7 @@ com.oqtane.maui - 10.0.0 + 10.0.1 1 @@ -54,11 +54,11 @@ - - - - - + + + + + diff --git a/Oqtane.Package/Oqtane.Client.nuspec b/Oqtane.Package/Oqtane.Client.nuspec index 6fea68b2..de21e194 100644 --- a/Oqtane.Package/Oqtane.Client.nuspec +++ b/Oqtane.Package/Oqtane.Client.nuspec @@ -2,7 +2,7 @@ Oqtane.Client - 10.0.0 + 10.0.1 Shaun Walker .NET Foundation Oqtane Framework @@ -12,18 +12,18 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0 + https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1 readme.md icon.png oqtane - - - - - + + + + + diff --git a/Oqtane.Package/Oqtane.Framework.nuspec b/Oqtane.Package/Oqtane.Framework.nuspec index 2a1dabff..952bb267 100644 --- a/Oqtane.Package/Oqtane.Framework.nuspec +++ b/Oqtane.Package/Oqtane.Framework.nuspec @@ -2,7 +2,7 @@ Oqtane.Framework - 10.0.0 + 10.0.1 Shaun Walker .NET Foundation Oqtane Framework @@ -11,8 +11,8 @@ .NET Foundation false MIT - https://github.com/oqtane/oqtane.framework/releases/download/v10.0.0/Oqtane.Framework.10.0.0.Upgrade.zip - https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0 + https://github.com/oqtane/oqtane.framework/releases/download/v10.0.1/Oqtane.Framework.10.0.1.Upgrade.zip + https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1 readme.md icon.png oqtane framework diff --git a/Oqtane.Package/Oqtane.Server.nuspec b/Oqtane.Package/Oqtane.Server.nuspec index 789f9215..47a0b13a 100644 --- a/Oqtane.Package/Oqtane.Server.nuspec +++ b/Oqtane.Package/Oqtane.Server.nuspec @@ -2,7 +2,7 @@ Oqtane.Server - 10.0.0 + 10.0.1 Shaun Walker .NET Foundation Oqtane Framework @@ -12,29 +12,29 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0 + https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1 readme.md icon.png oqtane - - - - - - + + + + + + - + - - - - + + + + diff --git a/Oqtane.Package/Oqtane.Shared.nuspec b/Oqtane.Package/Oqtane.Shared.nuspec index 9e68e8ca..78dfbf9d 100644 --- a/Oqtane.Package/Oqtane.Shared.nuspec +++ b/Oqtane.Package/Oqtane.Shared.nuspec @@ -2,7 +2,7 @@ Oqtane.Shared - 10.0.0 + 10.0.1 Shaun Walker .NET Foundation Oqtane Framework @@ -12,15 +12,15 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0 + https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1 readme.md icon.png oqtane - - - + + + diff --git a/Oqtane.Package/Oqtane.Updater.nuspec b/Oqtane.Package/Oqtane.Updater.nuspec index 597f5617..6873d041 100644 --- a/Oqtane.Package/Oqtane.Updater.nuspec +++ b/Oqtane.Package/Oqtane.Updater.nuspec @@ -2,7 +2,7 @@ Oqtane.Updater - 10.0.0 + 10.0.1 Shaun Walker .NET Foundation Oqtane Framework @@ -12,7 +12,7 @@ false MIT https://github.com/oqtane/oqtane.framework - https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0 + https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1 readme.md icon.png oqtane diff --git a/Oqtane.Package/install.ps1 b/Oqtane.Package/install.ps1 index a59e4412..6efebb86 100644 --- a/Oqtane.Package/install.ps1 +++ b/Oqtane.Package/install.ps1 @@ -1 +1 @@ -Compress-Archive -Path "..\Oqtane.Server\bin\Release\net10.0\publish\*" -DestinationPath "Oqtane.Framework.10.0.0.Install.zip" -Force +Compress-Archive -Path "..\Oqtane.Server\bin\Release\net10.0\publish\*" -DestinationPath "Oqtane.Framework.10.0.1.Install.zip" -Force diff --git a/Oqtane.Package/upgrade.ps1 b/Oqtane.Package/upgrade.ps1 index 5979f4d7..4f20bec7 100644 --- a/Oqtane.Package/upgrade.ps1 +++ b/Oqtane.Package/upgrade.ps1 @@ -1 +1 @@ -Compress-Archive -Path "..\Oqtane.Server\bin\Release\net10.0\publish\*" -DestinationPath "Oqtane.Framework.10.0.0.Upgrade.zip" -Force +Compress-Archive -Path "..\Oqtane.Server\bin\Release\net10.0\publish\*" -DestinationPath "Oqtane.Framework.10.0.1.Upgrade.zip" -Force diff --git a/Oqtane.Server/Components/App.razor b/Oqtane.Server/Components/App.razor index 7382976b..6276eea0 100644 --- a/Oqtane.Server/Components/App.razor +++ b/Oqtane.Server/Components/App.razor @@ -60,7 +60,7 @@ } @((MarkupString)_headResources) - + @if (string.IsNullOrEmpty(_message)) { @if (_renderMode == RenderModes.Static) @@ -97,6 +97,7 @@ private string _renderMode = RenderModes.Interactive; private string _runtime = Runtimes.Server; private bool _prerender = true; + Dictionary _bodyAttributes { get; set; } = new(); private string _fingerprint = ""; private int _visitorId = -1; private string _antiForgeryToken = ""; @@ -141,6 +142,10 @@ _renderMode = site.RenderMode; _runtime = site.Runtime; _prerender = site.Prerender; + if (_renderMode == RenderModes.Static && !site.EnhancedNavigation) + { + _bodyAttributes.Add("data-enhance-nav", "false"); + } _fingerprint = site.Fingerprint; var cookieConsentSettings = SettingService.GetSetting(site.Settings, "CookieConsent", string.Empty); @@ -170,6 +175,7 @@ if (page == null || page.IsDeleted) { HandlePageNotFound(site, page, route); + return; } else { @@ -231,7 +237,7 @@ Site = site, Page = page, Modules = modules, - User = null, + User = site.User, Uri = new Uri(url, UriKind.Absolute), Route = route, QueryString = Utilities.ParseQueryString(route.Query), @@ -300,8 +306,16 @@ { if (route.PagePath != "404") { - // redirect to 404 page - NavigationManager.NavigateTo(route.SiteUrl + "/404", true); + // handle not found request in static mode + if(_renderMode == RenderModes.Static) + { + NavigationManager.NotFound(); + } + else + { + // redirect to 404 page + NavigationManager.NavigateTo(route.SiteUrl + "/404", true); + } } } } diff --git a/Oqtane.Server/Controllers/PageController.cs b/Oqtane.Server/Controllers/PageController.cs index cb7b8ee5..69bed0bc 100644 --- a/Oqtane.Server/Controllers/PageController.cs +++ b/Oqtane.Server/Controllers/PageController.cs @@ -265,7 +265,19 @@ namespace Oqtane.Controllers _syncManager.AddSyncEvent(_alias, EntityNames.Site, page.SiteId, SyncEventActions.Refresh); // set user personalized page path - _settings.AddSetting(new Setting { EntityName = EntityNames.User, EntityId = page.UserId.Value, SettingName = $"PersonalizedPagePath:{page.SiteId}:{parent.PageId}", SettingValue = path, IsPrivate = false }); + var settingName = $"PersonalizedPagePath:{page.SiteId}:{parent.PageId}"; + var pathSetting = _settings.GetSetting(EntityNames.User, page.UserId.Value, settingName); + if(pathSetting == null) + { + pathSetting = new Setting { EntityName = EntityNames.User, EntityId = page.UserId.Value, SettingName = settingName, SettingValue = path, IsPrivate = false }; + _settings.AddSetting(pathSetting); + } + else + { + pathSetting.SettingValue = path; + _settings.UpdateSetting(pathSetting); + } + _syncManager.AddSyncEvent(_alias, EntityNames.User, user.UserId, SyncEventActions.Update); } } diff --git a/Oqtane.Server/Controllers/UserController.cs b/Oqtane.Server/Controllers/UserController.cs index 018e2695..ece7d295 100644 --- a/Oqtane.Server/Controllers/UserController.cs +++ b/Oqtane.Server/Controllers/UserController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Net; using System.Security.Claims; -using System.Security.Policy; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -294,14 +293,18 @@ namespace Oqtane.Controllers return user; } - // POST api//forgot - [HttpPost("forgot")] - public async Task Forgot([FromBody] User user) + // GET api//forgotpassword/x + [HttpGet("forgotpassword/{username}")] + public async Task ForgotPassword(string username) { - if (ModelState.IsValid) - { - await _userManager.ForgotPassword(user); - } + return await _userManager.ForgotPassword(username); + } + + // GET api//forgotusername/x + [HttpGet("forgotusername/{email}")] + public async Task ForgotUsername(string email) + { + return await _userManager.ForgotUsername(email); } // POST api//reset @@ -559,5 +562,12 @@ namespace Oqtane.Controllers HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden; } } + + // GET api//loginlink/x + [HttpGet("loginlink/{email}")] + public async Task SendLoginLink(string email) + { + return await _userManager.SendLoginLink(email); + } } } diff --git a/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs b/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs index fa3835ea..a18f4d02 100644 --- a/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs +++ b/Oqtane.Server/Extensions/ApplicationBuilderExtensions.cs @@ -1,8 +1,11 @@ using System; +using System.IO; using System.Linq; +using System.Net; using System.Reflection; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Cors.Infrastructure; +using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; @@ -65,6 +68,7 @@ namespace Oqtane.Extensions app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); + app.UseNotFoundResponse(); // execute any IServerStartup logic app.ConfigureOqtaneAssemblies(environment); @@ -146,5 +150,66 @@ namespace Oqtane.Extensions public static IApplicationBuilder UseExceptionMiddleWare(this IApplicationBuilder builder) => builder.UseMiddleware(); + + public static IApplicationBuilder UseNotFoundResponse(this IApplicationBuilder app) + { + const string notFoundRoute = "/404"; + app.UseStatusCodePagesWithReExecute(notFoundRoute, createScopeForStatusCodePages: true); + + app.Use(async (context, next) => + { + var path = context.Request.Path.Value ?? string.Empty; + if (string.IsNullOrEmpty(path) || ShouldSkipStatusCodeReExecution(path)) + { + var feature = context.Features.Get(); + feature?.Enabled = false; + } + + await next(); + }); + + app.Use(async (context, next) => + { + var feature = context.Features.Get(); + var handled = false; + if (feature != null + && context.Response.StatusCode == (int)HttpStatusCode.NotFound + && notFoundRoute.Equals(context.Request.Path.Value, StringComparison.OrdinalIgnoreCase)) + { + var alias = context.GetAlias(); + if (!string.IsNullOrEmpty(alias?.Path)) + { + var originalPath = context.Request.Path; + context.Request.Path = new PathString($"/{alias.Path}{notFoundRoute}"); + try + { + handled = true; + await next(); + } + finally + { + context.Request.Path = originalPath; + } + } + } + + if (!handled) + { + await next(); + } + }); + + return app; + } + + static bool ShouldSkipStatusCodeReExecution(string path) + { + return Constants.ReservedRoutes.Any(item => path.Contains("/" + item + "/")) || HasStaticFileExtension(path); + } + + static bool HasStaticFileExtension(string path) + { + return !string.IsNullOrEmpty(Path.GetExtension(path)); + } } } diff --git a/Oqtane.Server/Infrastructure/DatabaseManager.cs b/Oqtane.Server/Infrastructure/DatabaseManager.cs index 1d4debe7..afad9465 100644 --- a/Oqtane.Server/Infrastructure/DatabaseManager.cs +++ b/Oqtane.Server/Infrastructure/DatabaseManager.cs @@ -579,7 +579,6 @@ namespace Oqtane.Infrastructure site = new Site { - TenantId = tenant.TenantId, Name = install.SiteName, LogoFileId = null, FaviconFileId = null, @@ -596,7 +595,8 @@ namespace Oqtane.Infrastructure RenderMode = rendermode, Runtime = runtime, Prerender = (rendermode == RenderModes.Interactive), - Hybrid = false + Hybrid = false, + TenantId = tenant.TenantId }; site = sites.AddSite(site); diff --git a/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs b/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs index 9d320bd9..a280c71f 100644 --- a/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs +++ b/Oqtane.Server/Infrastructure/Jobs/NotificationJob.cs @@ -186,7 +186,7 @@ namespace Oqtane.Infrastructure var mailboxAddressValidationError = ""; // sender - if (settingRepository.GetSettingValue(settings, "SMTPRelay", "False") != "True") + if ((settingRepository.GetSettingValue(settings, "SMTPRelay", "False") == "True") && string.IsNullOrEmpty(fromEmail)) { fromEmail = settingRepository.GetSettingValue(settings, "SMTPSender", ""); fromName = string.IsNullOrEmpty(fromName) ? site.Name : fromName; diff --git a/Oqtane.Server/Infrastructure/Options/ISiteNamedOptions.cs b/Oqtane.Server/Infrastructure/Options/ISiteNamedOptions.cs deleted file mode 100644 index 41ed97c4..00000000 --- a/Oqtane.Server/Infrastructure/Options/ISiteNamedOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using Oqtane.Models; - -namespace Oqtane.Infrastructure -{ - public interface ISiteNamedOptions - where TOptions : class, new() - { - void Configure(string name, TOptions options, Alias alias, Dictionary sitesettings); - } -} diff --git a/Oqtane.Server/Infrastructure/Options/ISiteOptions.cs b/Oqtane.Server/Infrastructure/Options/ISiteOptions.cs deleted file mode 100644 index c05cd9fa..00000000 --- a/Oqtane.Server/Infrastructure/Options/ISiteOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using Oqtane.Models; - -namespace Oqtane.Infrastructure -{ - public interface ISiteOptions - where TOptions : class, new() - { - void Configure(TOptions options, Alias alias, Dictionary sitesettings); - } -} diff --git a/Oqtane.Server/Infrastructure/Options/SiteNamedOptions.cs b/Oqtane.Server/Infrastructure/Options/SiteNamedOptions.cs index 1027cc54..0ebe5a91 100644 --- a/Oqtane.Server/Infrastructure/Options/SiteNamedOptions.cs +++ b/Oqtane.Server/Infrastructure/Options/SiteNamedOptions.cs @@ -4,6 +4,12 @@ using Oqtane.Models; namespace Oqtane.Infrastructure { + public interface ISiteNamedOptions + where TOptions : class, new() + { + void Configure(string name, TOptions options, Alias alias, Dictionary sitesettings); + } + public class SiteNamedOptions : ISiteNamedOptions where TOptions : class, new() { diff --git a/Oqtane.Server/Infrastructure/Options/SiteOptions.cs b/Oqtane.Server/Infrastructure/Options/SiteOptions.cs index f4f55f77..e5d4e7c0 100644 --- a/Oqtane.Server/Infrastructure/Options/SiteOptions.cs +++ b/Oqtane.Server/Infrastructure/Options/SiteOptions.cs @@ -4,6 +4,12 @@ using Oqtane.Models; namespace Oqtane.Infrastructure { + public interface ISiteOptions + where TOptions : class, new() + { + void Configure(TOptions options, Alias alias, Dictionary sitesettings); + } + public class SiteOptions : ISiteOptions where TOptions : class, new() { diff --git a/Oqtane.Server/Managers/UserManager.cs b/Oqtane.Server/Managers/UserManager.cs index 4e95d5f6..3e032400 100644 --- a/Oqtane.Server/Managers/UserManager.cs +++ b/Oqtane.Server/Managers/UserManager.cs @@ -4,10 +4,8 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; -using System.Security.Policy; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Localization; using Oqtane.Enums; @@ -30,7 +28,8 @@ namespace Oqtane.Managers Task LoginUser(User user, bool setCookie, bool isPersistent); Task LogoutUserEverywhere(User user); Task VerifyEmail(User user, string token); - Task ForgotPassword(User user); + Task ForgotPassword(string username); + Task ForgotUsername(string email); Task ResetPassword(User user, string token); User VerifyTwoFactor(User user, string token); Task ValidateUser(string username, string email, string password); @@ -42,6 +41,7 @@ namespace Oqtane.Managers Task> GetLogins(int userId, int siteId); Task AddLogin(User user, string token, string type, string key, string name); Task DeleteLogin(int userId, string provider, string key); + Task SendLoginLink(string email); } public class UserManager : IUserManager @@ -279,7 +279,7 @@ namespace Oqtane.Managers await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated } - if (bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireConfirmedEmail", "true"))) + if (bool.Parse(_settings.GetSettingValue(EntityNames.Site, alias.SiteId, "LoginOptions:RequireConfirmedEmail", "true")) && !user.IsDeleted) { if (user.EmailConfirmed) { @@ -519,29 +519,73 @@ namespace Oqtane.Managers } return user; } - public async Task ForgotPassword(User user) + + public async Task ForgotPassword(string username) { - IdentityUser identityuser = await _identityUserManager.FindByNameAsync(user.Username); - if (identityuser != null) + if (!string.IsNullOrEmpty(username)) { - var alias = _tenantManager.GetAlias(); - user = _users.GetUser(user.Username); - string token = await _identityUserManager.GeneratePasswordResetTokenAsync(identityuser); - string url = alias.Protocol + alias.Name + "/reset?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); - string siteName = _sites.GetSite(alias.SiteId).Name; - string subject = _localizer["ForgotPasswordEmailSubject"]; - subject = subject.Replace("[SiteName]", siteName); - string body = _localizer["ForgotPasswordEmailBody"].Value; - body = body.Replace("[UserDisplayName]", user.DisplayName); - body = body.Replace("[URL]", url); - body = body.Replace("[SiteName]", siteName); - var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); - _notifications.AddNotification(notification); - _logger.Log(LogLevel.Information, this, LogFunction.Security, "Password Reset Notification Sent For {Username}", user.Username); + IdentityUser identityuser = await _identityUserManager.FindByNameAsync(username); + if (identityuser != null) + { + string token = await _identityUserManager.GeneratePasswordResetTokenAsync(identityuser); + + var alias = _tenantManager.GetAlias(); + var user = GetUser(username, alias.SiteId); + string url = alias.Protocol + alias.Name + "/reset?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); + string siteName = _sites.GetSite(alias.SiteId).Name; + string subject = _localizer["ForgotPasswordEmailSubject"]; + subject = subject.Replace("[SiteName]", siteName); + string body = _localizer["ForgotPasswordEmailBody"].Value; + body = body.Replace("[UserDisplayName]", user.DisplayName); + body = body.Replace("[URL]", url); + body = body.Replace("[SiteName]", siteName); + var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); + _notifications.AddNotification(notification); + + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Password Reset Notification Sent For {Username}", user.Username); + return true; + } } - else + + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Password Reset Notification Failed For {Username}", username); + return false; + } + + public async Task ForgotUsername(string email) + { + try { - _logger.Log(LogLevel.Error, this, LogFunction.Security, "Password Reset Notification Failed For {Username}", user.Username); + if (!string.IsNullOrEmpty(email)) + { + IdentityUser identityuser = await _identityUserManager.FindByEmailAsync(email); + if (identityuser != null) + { + var alias = _tenantManager.GetAlias(); + var user = GetUser(identityuser.UserName, alias.SiteId); + string url = alias.Protocol + alias.Name + "/login?name=" + user.Username; + string siteName = _sites.GetSite(alias.SiteId).Name; + string subject = _localizer["ForgotUsernameEmailSubject"]; + subject = subject.Replace("[SiteName]", siteName); + string body = _localizer["ForgotUsernameEmailBody"].Value; + body = body.Replace("[UserDisplayName]", user.DisplayName); + body = body.Replace("[URL]", url); + body = body.Replace("[SiteName]", siteName); + var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); + _notifications.AddNotification(notification); + + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Forgot Username Notification Sent For {Email}", user.Email); + return true; + } + } + + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Forgot Username Notification Failed For {Email}", email); + return false; + } + catch (Exception ex) + { + // email may not be unique + _logger.Log(LogLevel.Error, this, LogFunction.Security, ex, "Forgot Username Notification Failed For {Email}", email); + return false; } } @@ -588,6 +632,7 @@ namespace Oqtane.Managers } return user; } + public async Task ValidateUser(string username, string email, string password) { var validateResult = new UserValidateResult { Succeeded = true }; @@ -914,5 +959,45 @@ namespace Oqtane.Managers } } } + + public async Task SendLoginLink(string email) + { + try + { + if (!string.IsNullOrEmpty(email)) + { + IdentityUser identityuser = await _identityUserManager.FindByEmailAsync(email); + if (identityuser != null) + { + var token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); + + var alias = _tenantManager.GetAlias(); + var user = GetUser(identityuser.UserName, alias.SiteId); + string url = alias.Protocol + alias.Name + "/pages/loginlink?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); + string siteName = _sites.GetSite(alias.SiteId).Name; + string subject = _localizer["LoginLinkEmailSubject"]; + subject = subject.Replace("[SiteName]", siteName); + string body = _localizer["LoginLinkEmailBody"].Value; + body = body.Replace("[UserDisplayName]", user.DisplayName); + body = body.Replace("[URL]", url); + body = body.Replace("[SiteName]", siteName); + var notification = new Notification(_tenantManager.GetAlias().SiteId, user, subject, body); + _notifications.AddNotification(notification); + + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Login Link Notification Sent To {Email}", user.Email); + return true; + } + } + + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Notification Failed For {Email}", email); + return false; + } + catch (Exception ex) + { + // email may not be unique + _logger.Log(LogLevel.Error, this, LogFunction.Security, ex, "Login Link Notification Failed For {Email}", email); + return false; + } + } } } diff --git a/Oqtane.Server/Migrations/Tenant/10000101_AddSiteEnhancedNavigation.cs b/Oqtane.Server/Migrations/Tenant/10000101_AddSiteEnhancedNavigation.cs new file mode 100644 index 00000000..b119c2ee --- /dev/null +++ b/Oqtane.Server/Migrations/Tenant/10000101_AddSiteEnhancedNavigation.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations.EntityBuilders; +using Oqtane.Repository; + +namespace Oqtane.Migrations.Tenant +{ + [DbContext(typeof(TenantDBContext))] + [Migration("Tenant.10.00.01.01")] + public class AddSiteEnhancedNavigation : MultiDatabaseMigration + { + public AddSiteEnhancedNavigation(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var siteEntityBuilder = new SiteEntityBuilder(migrationBuilder, ActiveDatabase); + siteEntityBuilder.AddBooleanColumn("EnhancedNavigation", true); + siteEntityBuilder.UpdateData("EnhancedNavigation", true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // not implemented + } + } +} diff --git a/Oqtane.Server/Migrations/Tenant/10000102_RemoveSiteTenantId.cs b/Oqtane.Server/Migrations/Tenant/10000102_RemoveSiteTenantId.cs new file mode 100644 index 00000000..3ac75cbe --- /dev/null +++ b/Oqtane.Server/Migrations/Tenant/10000102_RemoveSiteTenantId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Oqtane.Databases.Interfaces; +using Oqtane.Migrations.EntityBuilders; +using Oqtane.Repository; + +namespace Oqtane.Migrations.Tenant +{ + [DbContext(typeof(TenantDBContext))] + [Migration("Tenant.10.00.01.02")] + public class RemoveSiteTenantId : MultiDatabaseMigration + { + public RemoveSiteTenantId(IDatabase database) : base(database) + { + } + + protected override void Up(MigrationBuilder migrationBuilder) + { + var siteEntityBuilder = new SiteEntityBuilder(migrationBuilder, ActiveDatabase); + siteEntityBuilder.DropIndex("IX_Site"); // TenantId, Name + siteEntityBuilder.DropColumn("TenantId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + // not implemented + } + } +} diff --git a/Oqtane.Server/Modules/HtmlText/Manager/HtmlTextManager.cs b/Oqtane.Server/Modules/HtmlText/Manager/HtmlTextManager.cs index bca9f928..67f05805 100644 --- a/Oqtane.Server/Modules/HtmlText/Manager/HtmlTextManager.cs +++ b/Oqtane.Server/Modules/HtmlText/Manager/HtmlTextManager.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; using Oqtane.Infrastructure; using Oqtane.Models; using Oqtane.Modules.HtmlText.Repository; @@ -7,11 +10,9 @@ using Oqtane.Repository; using Oqtane.Shared; using Oqtane.Migrations.Framework; using Oqtane.Documentation; -using System.Linq; using Oqtane.Interfaces; -using System.Collections.Generic; -using System; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; // ReSharper disable ConvertToUsingDeclaration @@ -23,15 +24,21 @@ namespace Oqtane.Modules.HtmlText.Manager private readonly IHtmlTextRepository _htmlText; private readonly IDBContextDependencies _DBContextDependencies; private readonly ISqlRepository _sqlRepository; + private readonly ITenantManager _tenantManager; + private readonly IMemoryCache _cache; public HtmlTextManager( IHtmlTextRepository htmlText, IDBContextDependencies DBContextDependencies, - ISqlRepository sqlRepository) + ISqlRepository sqlRepository, + ITenantManager tenantManager, + IMemoryCache cache) { _htmlText = htmlText; _DBContextDependencies = DBContextDependencies; _sqlRepository = sqlRepository; + _tenantManager = tenantManager; + _cache = cache; } public string ExportModule(Module module) @@ -71,6 +78,13 @@ namespace Oqtane.Modules.HtmlText.Manager htmlText.ModuleId = module.ModuleId; htmlText.Content = content; _htmlText.AddHtmlText(htmlText); + + //clear the cache for the module + var alias = _tenantManager.GetAlias(); + if(alias != null) + { + _cache.Remove($"HtmlText:{alias.SiteKey}:{module.ModuleId}"); + } } public bool Install(Tenant tenant, string version) diff --git a/Oqtane.Server/Oqtane.Server.csproj b/Oqtane.Server/Oqtane.Server.csproj index 1236dde0..1f282058 100644 --- a/Oqtane.Server/Oqtane.Server.csproj +++ b/Oqtane.Server/Oqtane.Server.csproj @@ -27,13 +27,13 @@ - - - - + + + + - + @@ -43,12 +43,12 @@ - + - - + + - + diff --git a/Oqtane.Server/Pages/LoginLink.cshtml b/Oqtane.Server/Pages/LoginLink.cshtml new file mode 100644 index 00000000..c069722c --- /dev/null +++ b/Oqtane.Server/Pages/LoginLink.cshtml @@ -0,0 +1,3 @@ +@page "/pages/loginlink" +@namespace Oqtane.Pages +@model Oqtane.Pages.LoginLinkModel diff --git a/Oqtane.Server/Pages/LoginLink.cshtml.cs b/Oqtane.Server/Pages/LoginLink.cshtml.cs new file mode 100644 index 00000000..d090c7ed --- /dev/null +++ b/Oqtane.Server/Pages/LoginLink.cshtml.cs @@ -0,0 +1,67 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Oqtane.Enums; +using Oqtane.Extensions; +using Oqtane.Infrastructure; +using Oqtane.Managers; +using Oqtane.Shared; + +namespace Oqtane.Pages +{ + [AllowAnonymous] + public class LoginLinkModel : PageModel + { + private readonly UserManager _identityUserManager; + private readonly SignInManager _identitySignInManager; + private readonly ILogManager _logger; + + public LoginLinkModel(UserManager identityUserManager, SignInManager identitySignInManager, ILogManager logger) + { + _identityUserManager = identityUserManager; + _identitySignInManager = identitySignInManager; + _logger = logger; + } + + public async Task OnGetAsync(string name, string token) + { + var returnurl = "/login"; + + if (bool.Parse(HttpContext.GetSiteSettings().GetValue("LoginOptions:LoginLink", "false")) && + !User.Identity.IsAuthenticated && !string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(token)) + { + var validuser = false; + + IdentityUser identityuser = await _identityUserManager.FindByNameAsync(name); + if (identityuser != null) + { + var result = await _identityUserManager.ConfirmEmailAsync(identityuser, token); + if (result.Succeeded) + { + await _identitySignInManager.SignInAsync(identityuser, false); + _logger.Log(LogLevel.Information, this, LogFunction.Security, "Login Link Successful For User {Username}", name); + validuser = true; + returnurl = "/"; + } + } + + if (!validuser) + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Login Link Failed For User {Username}", name); + returnurl += $"?status={ExternalLoginStatus.LoginLinkFailed}"; + } + } + else + { + _logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Login Link Attempt For User {Username}", name); + returnurl = "/"; + } + + return LocalRedirect(Url.Content("~" + returnurl)); + } + } +} diff --git a/Oqtane.Server/Resources/Managers/UserManager.resx b/Oqtane.Server/Resources/Managers/UserManager.resx index ecc38fb0..64c55528 100644 --- a/Oqtane.Server/Resources/Managers/UserManager.resx +++ b/Oqtane.Server/Resources/Managers/UserManager.resx @@ -121,7 +121,19 @@ Dear [UserDisplayName]<br><br>You recently requested to reset your password. Please use the link below to complete the process: <b><a href="[URL]"><br><br>Click here to Reset Password</a></b><br><br>Please note that the link is only valid for 24 hours so if you are unable to take action within that time period, you should initiate another password reset on the site.<br><br>If you did not request to reset your password you can safely ignore this message.<br><br>Thank You!<br>[SiteName] Team - Password Reset Notification Sent For [SiteName] + Password Reset Notification For [SiteName] + + + Dear [UserDisplayName]<br><br>You recently requested a username reminder. Please use the link below to complete the process: <b><a href="[URL]"><br><br>Click here to Login</a></b><br><br>If you did not request a username reminder you can safely ignore this message.<br><br>Thank You!<br>[SiteName] Team + + + Forgotten Username Reminder For [SiteName] + + + Dear [UserDisplayName]<br><br>You recently requested a login link. Please use the link below to complete the process: <b><a href="[URL]"><br><br>Click here to Login</a></b><br><br>Please note that the link is only valid for 10 minutes so if you are unable to take action within that time period, you should initiate another login link request on the site.<br><br>If you did not request a login link you can safely ignore this message.<br><br>Thank You!<br>[SiteName] Team + + + Login Link Notification For [SiteName] Dear [UserDisplayName],<br><br>A user account has been successfully created for you with the username <b>[Username]</b>. Please <b><a href="[URL]">click here to login</a></b>. If you do not know your password, use the forgot password option on the login page to reset your account.<br><br>Thank You!<br>[SiteName] Team diff --git a/Oqtane.Server/Services/SiteService.cs b/Oqtane.Server/Services/SiteService.cs index 25a9ec92..ef7b32d6 100644 --- a/Oqtane.Server/Services/SiteService.cs +++ b/Oqtane.Server/Services/SiteService.cs @@ -13,6 +13,7 @@ using Oqtane.Enums; using Oqtane.Shared; using System.Globalization; using Oqtane.Extensions; +using Oqtane.Managers; namespace Oqtane.Services { @@ -25,6 +26,7 @@ namespace Oqtane.Services private readonly IPageModuleRepository _pageModules; private readonly IModuleDefinitionRepository _moduleDefinitions; private readonly ILanguageRepository _languages; + private readonly IUserManager _userManager; private readonly IUserPermissions _userPermissions; private readonly ISettingRepository _settings; private readonly ITenantManager _tenantManager; @@ -35,7 +37,7 @@ namespace Oqtane.Services private readonly IHttpContextAccessor _accessor; private readonly string _private = "[PRIVATE]"; - public ServerSiteService(ISiteRepository sites, IPageRepository pages, IThemeRepository themes, IPageModuleRepository pageModules, IModuleDefinitionRepository moduleDefinitions, ILanguageRepository languages, IUserPermissions userPermissions, ISettingRepository settings, ITenantManager tenantManager, ISyncManager syncManager, IConfigManager configManager, ILogManager logger, IMemoryCache cache, IHttpContextAccessor accessor) + public ServerSiteService(ISiteRepository sites, IPageRepository pages, IThemeRepository themes, IPageModuleRepository pageModules, IModuleDefinitionRepository moduleDefinitions, ILanguageRepository languages, IUserManager userManager, IUserPermissions userPermissions, ISettingRepository settings, ITenantManager tenantManager, ISyncManager syncManager, IConfigManager configManager, ILogManager logger, IMemoryCache cache, IHttpContextAccessor accessor) { _sites = sites; _pages = pages; @@ -43,6 +45,7 @@ namespace Oqtane.Services _pageModules = pageModules; _moduleDefinitions = moduleDefinitions; _languages = languages; + _userManager = userManager; _userPermissions = userPermissions; _settings = settings; _tenantManager = tenantManager; @@ -101,6 +104,12 @@ namespace Oqtane.Services } site.Languages = site.Languages.OrderBy(item => item.Name).ToList(); + // get user + if (_accessor.HttpContext.User.IsAuthenticated()) + { + site.User = _userManager.GetUser(_accessor.HttpContext.User.UserId(), site.SiteId); + } + return Task.FromResult(site); } @@ -148,6 +157,8 @@ namespace Oqtane.Services // installation date used for fingerprinting static assets site.Fingerprint = Utilities.GenerateSimpleHash(_configManager.GetSetting("InstallationDate", DateTime.UtcNow.ToString("yyyyMMddHHmm"))); + + site.TenantId = alias.TenantId; } else { @@ -181,7 +192,7 @@ namespace Oqtane.Services { var alias = _tenantManager.GetAlias(); var current = _sites.GetSite(site.SiteId, false); - if (site.SiteId == alias.SiteId && site.TenantId == alias.TenantId && current != null) + if (site.SiteId == alias.SiteId && current != null) { site = _sites.UpdateSite(site); _syncManager.AddSyncEvent(alias, EntityNames.Site, site.SiteId, SyncEventActions.Update); diff --git a/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css b/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css index 38c1a2c9..38010da6 100644 --- a/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css +++ b/Oqtane.Server/wwwroot/Modules/Oqtane.Modules.Admin.Login/Module.css @@ -1,5 +1,5 @@ /* Login Module Custom Styles */ .Oqtane-Modules-Admin-Login { - width: 220px; + width: 280px; } diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj index 607195bc..2afa3718 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Client/[Owner].Module.[Module].Client.csproj @@ -13,11 +13,11 @@ - - - - - + + + + + diff --git a/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj b/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj index 4e70b5b0..95f39be0 100644 --- a/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj +++ b/Oqtane.Server/wwwroot/Modules/Templates/External/Server/[Owner].Module.[Module].Server.csproj @@ -20,10 +20,10 @@ - - - - + + + + diff --git a/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj b/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj index 8333d87e..ca8517c6 100644 --- a/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj +++ b/Oqtane.Server/wwwroot/Themes/Templates/External/Client/[Owner].Theme.[Theme].Client.csproj @@ -14,9 +14,9 @@ - - - + + + diff --git a/Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs b/Oqtane.Shared/Extensions/ClaimsPrincipalExtensions.cs similarity index 91% rename from Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs rename to Oqtane.Shared/Extensions/ClaimsPrincipalExtensions.cs index 1749c421..2adfbcde 100644 --- a/Oqtane.Server/Extensions/ClaimsPrincipalExtensions.cs +++ b/Oqtane.Shared/Extensions/ClaimsPrincipalExtensions.cs @@ -8,6 +8,18 @@ namespace Oqtane.Extensions { // extension methods cannot be properties - the methods below must include a () suffix when referenced + public static bool IsAuthenticated(this ClaimsPrincipal claimsPrincipal) + { + if (claimsPrincipal.Identity != null) + { + return claimsPrincipal.Identity.IsAuthenticated; + } + else + { + return false; + } + } + public static string Username(this ClaimsPrincipal claimsPrincipal) { if (claimsPrincipal.HasClaim(item => item.Type == ClaimTypes.Name)) diff --git a/Oqtane.Shared/Models/Site.cs b/Oqtane.Shared/Models/Site.cs index e674be2e..4ba4ede5 100644 --- a/Oqtane.Shared/Models/Site.cs +++ b/Oqtane.Shared/Models/Site.cs @@ -16,11 +16,6 @@ namespace Oqtane.Models /// public int SiteId { get; set; } - /// - /// Reference to the the Site is in - /// - public int TenantId { get; set; } - /// /// The site Name /// @@ -115,6 +110,11 @@ namespace Oqtane.Models /// public bool Hybrid { get; set; } + /// + /// Indicates if enhanced navigation should be used with static rendering + /// + public bool EnhancedNavigation { get; set; } + /// /// Keeps track of site configuration changes and is used by the ISiteMigration interface /// @@ -192,18 +192,29 @@ namespace Oqtane.Models [NotMapped] public List Themes { get; set; } + /// + /// Current user + /// + [NotMapped] + public User User { get; set; } + /// /// fingerprint for framework static assets /// [NotMapped] public string Fingerprint { get; set; } + /// + /// Reference to the the Site belongs to + /// + [NotMapped] + public int TenantId { get; set; } + public Site Clone() { return new Site { SiteId = SiteId, - TenantId = TenantId, Name = Name, TimeZoneId = TimeZoneId, LogoFileId = LogoFileId, @@ -222,6 +233,7 @@ namespace Oqtane.Models Runtime = Runtime, Prerender = Prerender, Hybrid = Hybrid, + EnhancedNavigation = EnhancedNavigation, Version = Version, HomePageId = HomePageId, HeadContent = HeadContent, @@ -240,7 +252,9 @@ namespace Oqtane.Models Pages = Pages.ConvertAll(page => page.Clone()), Languages = Languages.ConvertAll(language => language.Clone()), Themes = Themes, - Fingerprint = Fingerprint + User = User?.Clone(), + Fingerprint = Fingerprint, + TenantId = TenantId }; } diff --git a/Oqtane.Shared/Models/User.cs b/Oqtane.Shared/Models/User.cs index 7b6b398b..dd3fa320 100644 --- a/Oqtane.Shared/Models/User.cs +++ b/Oqtane.Shared/Models/User.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Xml.Linq; namespace Oqtane.Models { @@ -128,5 +130,34 @@ namespace Oqtane.Models /// [NotMapped] public Dictionary Settings { get; set; } + + public User Clone() + { + return new User + { + UserId = UserId, + Username = Username, + DisplayName = DisplayName, + Email = Email, + TimeZoneId = TimeZoneId, + PhotoFileId = PhotoFileId, + LastLoginOn = LastLoginOn, + LastIPAddress = LastIPAddress, + TwoFactorRequired = TwoFactorRequired, + TwoFactorCode = TwoFactorCode, + TwoFactorExpiry = TwoFactorExpiry, + SecurityStamp = SecurityStamp, + SiteId = SiteId, + Roles = Roles, + DeletedBy = DeletedBy, + DeletedOn = DeletedOn, + IsDeleted = IsDeleted, + Password = Password, + IsAuthenticated = IsAuthenticated, + EmailConfirmed = EmailConfirmed, + SuppressNotification = SuppressNotification, + Settings = Settings.ToDictionary(setting => setting.Key, setting => setting.Value) + }; + } } } diff --git a/Oqtane.Shared/Oqtane.Shared.csproj b/Oqtane.Shared/Oqtane.Shared.csproj index c1bdff46..8f7ddde6 100644 --- a/Oqtane.Shared/Oqtane.Shared.csproj +++ b/Oqtane.Shared/Oqtane.Shared.csproj @@ -5,9 +5,9 @@ - - - + + + diff --git a/Oqtane.Shared/Shared/Constants.cs b/Oqtane.Shared/Shared/Constants.cs index d6740658..3189ccce 100644 --- a/Oqtane.Shared/Shared/Constants.cs +++ b/Oqtane.Shared/Shared/Constants.cs @@ -4,8 +4,8 @@ namespace Oqtane.Shared { public class Constants { - public static readonly string Version = "10.0.0"; - public const string ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4,2.0.0,2.0.1,2.0.2,2.1.0,2.2.0,2.3.0,2.3.1,3.0.0,3.0.1,3.0.2,3.0.3,3.1.0,3.1.1,3.1.2,3.1.3,3.1.4,3.2.0,3.2.1,3.3.0,3.3.1,3.4.0,3.4.1,3.4.2,3.4.3,4.0.0,4.0.1,4.0.2,4.0.3,4.0.4,4.0.5,4.0.6,5.0.0,5.0.1,5.0.2,5.0.3,5.1.0,5.1.1,5.1.2,5.2.0,5.2.1,5.2.2,5.2.3,5.2.4,6.0.0,6.0.1,6.1.0,6.1.1,6.1.2,6.1.3,6.1.4,6.1.5,6.2.0,6.2.1,10.0.0"; + public static readonly string Version = "10.0.1"; + public const string ReleaseVersions = "1.0.0,1.0.1,1.0.2,1.0.3,1.0.4,2.0.0,2.0.1,2.0.2,2.1.0,2.2.0,2.3.0,2.3.1,3.0.0,3.0.1,3.0.2,3.0.3,3.1.0,3.1.1,3.1.2,3.1.3,3.1.4,3.2.0,3.2.1,3.3.0,3.3.1,3.4.0,3.4.1,3.4.2,3.4.3,4.0.0,4.0.1,4.0.2,4.0.3,4.0.4,4.0.5,4.0.6,5.0.0,5.0.1,5.0.2,5.0.3,5.1.0,5.1.1,5.1.2,5.2.0,5.2.1,5.2.2,5.2.3,5.2.4,6.0.0,6.0.1,6.1.0,6.1.1,6.1.2,6.1.3,6.1.4,6.1.5,6.2.0,6.2.1,10.0.0,10.0.1"; public const string PackageId = "Oqtane.Framework"; public const string ClientId = "Oqtane.Client"; public const string UpdaterPackageId = "Oqtane.Updater"; diff --git a/Oqtane.Shared/Shared/ExternalLoginStatus.cs b/Oqtane.Shared/Shared/ExternalLoginStatus.cs index 63cd0094..8423d799 100644 --- a/Oqtane.Shared/Shared/ExternalLoginStatus.cs +++ b/Oqtane.Shared/Shared/ExternalLoginStatus.cs @@ -10,5 +10,6 @@ namespace Oqtane.Shared { public const string AccessDenied = "AccessDenied"; public const string RemoteFailure = "RemoteFailure"; public const string ReviewClaims = "ReviewClaims"; + public const string LoginLinkFailed = "LoginLinkFailed"; } } diff --git a/Oqtane.Shared/Shared/Utilities.cs b/Oqtane.Shared/Shared/Utilities.cs index 44cccffc..45888f0c 100644 --- a/Oqtane.Shared/Shared/Utilities.cs +++ b/Oqtane.Shared/Shared/Utilities.cs @@ -4,6 +4,8 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Reflection; +using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -750,6 +752,75 @@ namespace Oqtane.Shared } } + public static IEnumerable GetPropertiesIncludingInherited(Type type, BindingFlags bindingFlags) + { + var dictionary = new Dictionary(StringComparer.Ordinal); + + var currentType = type; + while (currentType != null) + { + var properties = currentType.GetProperties(bindingFlags | BindingFlags.DeclaredOnly); + foreach (var property in properties) + { + if (!dictionary.TryGetValue(property.Name, out var others)) + { + dictionary.Add(property.Name, property); + } + else if (!IsInheritedProperty(property, others)) + { + List many; + if (others is PropertyInfo single) + { + many = new List { single }; + dictionary[property.Name] = many; + } + else + { + many = (List)others; + } + many.Add(property); + } + } + + currentType = currentType.BaseType; + } + + foreach (var item in dictionary) + { + if (item.Value is PropertyInfo property) + { + yield return property; + continue; + } + + var list = (List)item.Value; + var count = list.Count; + for (var i = 0; i < count; i++) + { + yield return list[i]; + } + } + } + + private static bool IsInheritedProperty(PropertyInfo property, object others) + { + if (others is PropertyInfo single) + { + return single.GetMethod?.GetBaseDefinition() == property.GetMethod?.GetBaseDefinition(); + } + + var many = (List)others; + foreach (var other in CollectionsMarshal.AsSpan(many)) + { + if (other.GetMethod?.GetBaseDefinition() == property.GetMethod?.GetBaseDefinition()) + { + return true; + } + } + + return false; + } + [Obsolete("ContentUrl(Alias alias, int fileId) is deprecated. Use FileUrl(Alias alias, int fileId) instead.", false)] public static string ContentUrl(Alias alias, int fileId) { diff --git a/README.md b/README.md index 6d7709a3..03b1c488 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Oqtane is being developed based on some fundamental principles which are outline # Latest Release -[10.0.0](https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0) was released on November 14, 2025 and is a maintenance release including 77 pull requests by 6 different contributors, pushing the total number of project commits all-time over 7300. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. +[10.0.1](https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1) was released on December 15, 2025 and is a major release including 38 pull requests by 5 different contributors, pushing the total number of project commits all-time over 7400. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. # Try It Now! @@ -26,7 +26,11 @@ A free ASP.NET hosting account. No hidden fees. No credit card required. **Installing using the Oqtane Application Template:** -(Note that "MyCompany.MyProject" can be replaced with your own unique company and project name) +If you have an older version of the Oqtane Application Template installed and want to use the latest, use the following .NET CLI command to uninstall the old version: +``` +dotnet new uninstall Oqtane.Application.Template +``` +To install the Oqtane Application Template and create a new project, use the following .NET CLI commands (note that "MyCompany.MyProject" can be replaced with your own unique company and project name): ``` dotnet new install Oqtane.Application.Template @@ -38,7 +42,7 @@ dotnet run ``` - Browse to the Url specified to run the application (an Installation Wizard screen will be displayed the first time you run the application) -- To develop/debug the application in an IDE, open the *.sln file in the root folder and hit F5 +- To develop/debug the application in an IDE, open the *.slnx file in the root folder and hit F5 **Installing using source code from the Dev/Master branch:** @@ -46,7 +50,10 @@ dotnet run - Install the latest edition of [Visual Studio 2026](https://visualstudio.microsoft.com/downloads) with the **ASP.NET and web development** workload enabled. Oqtane works with ALL editions of Visual Studio from Community to Enterprise. If you wish to use LocalDB for development ( not a requirement as Oqtane supports SQLite, mySQL, and PostgreSQL ) you must also install the **Data storage and processing**. -- Clone (or download) the Oqtane Master or Dev branch source code to your local system. +- Clone (or download) the Oqtane source code to your local system: + + - Dev Branch: git clone https://github.com/oqtane/oqtane.framework + - Master Branch: git clone --single-branch --branch master https://github.com/oqtane/oqtane.framework - Open the **Oqtane.slnx** solution file (make sure you specify Oqtane.Server as the Startup Project) @@ -81,7 +88,7 @@ dotnet run - If you have already installed a previous version of Oqtane and you wish to do a clean database install, simply reset the DefaultConnection value in the Oqtane.Server\appsettings.json file to "". This will trigger a re-install when you run the application which will execute the database installation. -- If you want to submit pull requests make sure you install the [Github Extension For Visual Studio](https://visualstudio.github.com/). It is recommended you ignore any local changes you have made to the appsettings.json file before you submit a pull request. To automate this activity, open a command prompt and navigate to the /Oqtane.Server/ folder and enter the command "git update-index --skip-worktree appsettings.json" +- If you want to submit pull requests it is recommended you ignore any local changes you have made to the appsettings.json file before you submit a pull request. To automate this activity, open a command prompt and navigate to the /Oqtane.Server/ folder and enter the command "git update-index --skip-worktree appsettings.json" **Video Series** @@ -104,6 +111,9 @@ Connect with other developers, get support, and share ideas by joining the Oqtan # Roadmap This project is open source, and therefore is a work in progress... +[10.0.1](https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.1) (Dec 15, 2025) +- [x] Stabilization improvements + [10.0.0](https://github.com/oqtane/oqtane.framework/releases/tag/v10.0.0) (Nov 14, 2025) - [x] Migration to .NET 10 - [x] Passkey Authentication @@ -190,7 +200,7 @@ This project is open source, and therefore is a work in progress... ➡️ Full list and older versions can be found in the [docs roadmap](https://docs.oqtane.org/guides/roadmap/index.html) # Background -Oqtane was created by [Shaun Walker](https://www.linkedin.com/in/shaunbrucewalker/) and is inspired by the DotNetNuke web application framework. Oqtane is a native Blazor application written from the ground up using modern .NET Core technology and a Single Page Application (SPA) architecture. It is a modular application framework offering a fully dynamic page compositing model, multi-site support, designer friendly themes, and extensibility via third party modules. +Oqtane was created by [Shaun Walker](https://www.linkedin.com/in/shaunbrucewalker/) and was inspired by his earlier efforts creating the DotNetNuke web application framework for the .NET Framework. Oqtane is a native Blazor application written from the ground up using modern .NET Core technology and a Single Page Application (SPA) architecture. It is a modular application framework offering a fully dynamic page compositing model, multi-site support, designer friendly themes, and extensibility via third party modules. # Reference Implementations diff --git a/azuredeploy.json b/azuredeploy.json index 4b3df91b..54ab2451 100644 --- a/azuredeploy.json +++ b/azuredeploy.json @@ -220,7 +220,7 @@ "apiVersion": "2024-04-01", "name": "[concat(parameters('BlazorWebsiteName'), '/ZipDeploy')]", "properties": { - "packageUri": "https://github.com/oqtane/oqtane.framework/releases/download/v10.0.0/Oqtane.Framework.10.0.0.Install.zip" + "packageUri": "https://github.com/oqtane/oqtane.framework/releases/download/v10.0.1/Oqtane.Framework.10.0.1.Install.zip" }, "dependsOn": [ "[resourceId('Microsoft.Web/sites', parameters('BlazorWebsiteName'))]"