Merge remote-tracking branch 'upstream/dev' into Bootstrap

This commit is contained in:
Leigh Pointer
2025-05-30 16:06:19 +02:00
30 changed files with 502 additions and 243 deletions

View File

@ -144,7 +144,7 @@ else
user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]); user = await UserService.VerifyEmailAsync(user, PageState.QueryString["token"]);
if (user != null) if (user != null)
{ {
await logger.LogInformation(LogFunction.Security, "Email Verified For For Username {Username}", _username); await logger.LogInformation(LogFunction.Security, "Email Verified For Username {Username}", _username);
AddModuleMessage(Localizer["Success.Account.Verified"], MessageType.Info); AddModuleMessage(Localizer["Success.Account.Verified"], MessageType.Info);
} }
else else

View File

@ -5,24 +5,57 @@
@inject IStringLocalizer<Export> Localizer @inject IStringLocalizer<Export> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer @inject IStringLocalizer<SharedResources> SharedLocalizer
<TabStrip>
<TabPanel Name="Content" Heading="Content" ResourceKey="Content">
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="content" HelpText="The Exported Module Content" ResourceKey="Content">Content: </Label> <Label Class="col-sm-3" For="content" HelpText="Select the Export option and you will be able to view the module content" ResourceKey="Content">Content: </Label>
<div class="col-sm-9"> <div class="col-sm-9">
<textarea id="content" class="form-control" @bind="@_content" rows="5" readonly></textarea> <textarea id="content" class="form-control" @bind="@_content" rows="5" readonly></textarea>
</div> </div>
</div> </div>
</div> </div>
<br />
<button type="button" class="btn btn-success" @onclick="ExportModule">@Localizer["Export"]</button> <button type="button" class="btn btn-success" @onclick="ExportText">@Localizer["Export"]</button>
<NavLink class="btn btn-secondary" href="@PageState.ReturnUrl">@SharedLocalizer["Cancel"]</NavLink> <NavLink class="btn btn-secondary" href="@PageState.ReturnUrl">@SharedLocalizer["Cancel"]</NavLink>
</TabPanel>
<TabPanel Name="File" Heading="File" ResourceKey="File">
<div class="container">
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="folder" HelpText="Select a folder where you wish to save the exported content" ResourceKey="Folder">Folder: </Label>
<div class="col-sm-9">
<FileManager ShowFiles="false" ShowUpload="false" @ref="_filemanager" />
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="filename" HelpText="Specify a name for the file (without an extension)" ResourceKey="Filename">Filename: </Label>
<div class="col-sm-9">
<input id="content" type="text" class="form-control" @bind="@_filename" />
</div>
</div>
</div>
<br />
<button type="button" class="btn btn-success" @onclick="ExportFile">@Localizer["Export"]</button>
<NavLink class="btn btn-secondary" href="@PageState.ReturnUrl">@SharedLocalizer["Cancel"]</NavLink>
</TabPanel>
</TabStrip>
@code { @code {
private string _content = string.Empty; private string _content = string.Empty;
private FileManager _filemanager;
private string _filename = string.Empty;
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit;
public override string Title => "Export Content"; public override string Title => "Export Content";
private async Task ExportModule() protected override void OnInitialized()
{
_filename = Utilities.GetFriendlyUrl(ModuleState.Title);
}
private async Task ExportText()
{ {
try try
{ {
@ -35,4 +68,34 @@
AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error); AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error);
} }
} }
private async Task ExportFile()
{
try
{
var folderid = _filemanager.GetFolderId();
if (folderid != -1 && !string.IsNullOrEmpty(_filename))
{
var fileid = await ModuleService.ExportModuleAsync(ModuleState.ModuleId, PageState.Page.PageId, folderid, _filename);
if (fileid != -1)
{
AddModuleMessage(Localizer["Success.Content.Export"], MessageType.Success);
}
else
{
AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error);
}
}
else
{
AddModuleMessage(Localizer["Message.Content.Export"], MessageType.Warning);
}
}
catch (Exception ex)
{
await logger.LogError(ex, "Error Exporting Module {ModuleId} {Error}", ModuleState.ModuleId, ex.Message);
AddModuleMessage(Localizer["Error.Module.Export"], MessageType.Error);
}
}
} }

View File

@ -2,20 +2,27 @@
@inherits ModuleBase @inherits ModuleBase
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IModuleService ModuleService @inject IModuleService ModuleService
@inject IFileService FileService
@inject IStringLocalizer<Import> Localizer @inject IStringLocalizer<Import> Localizer
@inject IStringLocalizer<SharedResources> SharedLocalizer @inject IStringLocalizer<SharedResources> SharedLocalizer
<form @ref="form" class="@(validated ? "was-validated" : "needs-validation")" novalidate> <form @ref="form" class="@(validated ? "was-validated" : "needs-validation")" novalidate>
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="content" HelpText="Enter The Module Content To Import" ResourceKey="Content">Content: </Label> <Label Class="col-sm-3" For="file" HelpText="Optionally upload or select a file to import for this module" ResourceKey="File">File: </Label>
<div class="col-sm-9">
<FileManager Filter="json" OnSelectFile="OnSelectFile" />
</div>
</div>
<hr />
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="content" HelpText="Provide the module content to import" ResourceKey="Content">Content: </Label>
<div class="col-sm-9"> <div class="col-sm-9">
<textarea id="content" class="form-control" @bind="@_content" rows="5" required></textarea> <textarea id="content" class="form-control" @bind="@_content" rows="5" required></textarea>
</div> </div>
</div> </div>
</div> </div>
<br />
<button type="button" class="btn btn-success" @onclick="ImportModule">@Localizer["Import"]</button> <button type="button" class="btn btn-success" @onclick="ImportModule">@Localizer["Import"]</button>
<NavLink class="btn btn-secondary" href="@PageState.ReturnUrl">@SharedLocalizer["Cancel"]</NavLink> <NavLink class="btn btn-secondary" href="@PageState.ReturnUrl">@SharedLocalizer["Cancel"]</NavLink>
</form> </form>
@ -28,6 +35,12 @@
public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit; public override SecurityAccessLevel SecurityAccessLevel => SecurityAccessLevel.Edit;
public override string Title => "Import Content"; public override string Title => "Import Content";
private async Task OnSelectFile(int fileId)
{
var bytes = await FileService.DownloadFileAsync(fileId);
_content = System.Text.Encoding.UTF8.GetString(bytes, 0, bytes.Length);
}
private async Task ImportModule() private async Task ImportModule()
{ {
validated = true; validated = true;

View File

@ -101,15 +101,15 @@
<Section Name="ModuleContent" Heading="Content" ResourceKey="ModuleContent"> <Section Name="ModuleContent" Heading="Content" ResourceKey="ModuleContent">
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="header" HelpText="Optionally provide content to be injected above the module instance" ResourceKey="Header">Header: </Label> <Label Class="col-sm-3" For="moduleheader" HelpText="Optionally provide content to be injected above the module instance" ResourceKey="Header">Header: </Label>
<div class="col-sm-9"> <div class="col-sm-9">
<textarea id="header" class="form-control" @bind="@_header" rows="3" maxlength="4000"></textarea> <textarea id="moduleheader" class="form-control" @bind="@_header" rows="3" maxlength="4000"></textarea>
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="footer" HelpText="Optionally provide content to be injected below the module instance" ResourceKey="Footer">Footer: </Label> <Label Class="col-sm-3" For="modulefooter" HelpText="Optionally provide content to be injected below the module instance" ResourceKey="Footer">Footer: </Label>
<div class="col-sm-9"> <div class="col-sm-9">
<textarea id="footer" class="form-control" @bind="@_footer" rows="3" maxlength="4000"></textarea> <textarea id="modulefooter" class="form-control" @bind="@_footer" rows="3" maxlength="4000"></textarea>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,6 +8,8 @@
@inject IStringLocalizer<SharedResources> SharedLocalizer @inject IStringLocalizer<SharedResources> SharedLocalizer
@inject ISettingService SettingService @inject ISettingService SettingService
@if (_initialized)
{
@if (PageState.Site.AllowRegistration) @if (PageState.Site.AllowRegistration)
{ {
if (!_userCreated) if (!_userCreated)
@ -88,8 +90,10 @@ else
{ {
<ModuleMessage Message="@Localizer["Info.Registration.Disabled"]" Type="MessageType.Info" /> <ModuleMessage Message="@Localizer["Info.Registration.Disabled"]" Type="MessageType.Info" />
} }
}
@code { @code {
private bool _initialized = false;
private List<Models.TimeZone> _timezones; private List<Models.TimeZone> _timezones;
private string _passwordrequirements; private string _passwordrequirements;
private string _username = string.Empty; private string _username = string.Empty;
@ -113,6 +117,7 @@ else
_allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true")); _allowsitelogin = bool.Parse(SettingService.GetSetting(PageState.Site.Settings, "LoginOptions:AllowSiteLogin", "true"));
_timezones = await TimeZoneService.GetTimeZonesAsync(); _timezones = await TimeZoneService.GetTimeZonesAsync();
_timezoneid = PageState.Site.TimeZoneId; _timezoneid = PageState.Site.TimeZoneId;
_initialized = true;
} }
protected override void OnParametersSet() protected override void OnParametersSet()

View File

@ -8,7 +8,7 @@
<form @ref="form" class="@(validated ? "was-validated" : "needs-validation")" novalidate> <form @ref="form" class="@(validated ? "was-validated" : "needs-validation")" novalidate>
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="url" HelpText="An absolute Url for this site" ResourceKey="Url">Url:</Label> <Label Class="col-sm-3" For="url" HelpText="A Url identifying a path to a specific page in the site (absolute or relative)" ResourceKey="Url">Url:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<div class="input-group"> <div class="input-group">
<input id="url" class="form-control" @bind="@_url" maxlength="500" required /> <input id="url" class="form-control" @bind="@_url" maxlength="500" required />
@ -17,7 +17,7 @@
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="mappedurl" HelpText="A fully qualified Url where the user will be redirected" ResourceKey="MappedUrl">Redirect To:</Label> <Label Class="col-sm-3" For="mappedurl" HelpText="A Url where the user will be redirected (absolute or relative). Use '/' for site root path." ResourceKey="MappedUrl">Redirect To:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="mappedurl" class="form-control" @bind="@_mappedurl" maxlength="500" required /> <input id="mappedurl" class="form-control" @bind="@_mappedurl" maxlength="500" required />
</div> </div>

View File

@ -8,13 +8,13 @@
<form @ref="form" class="@(validated ? "was-validated" : "needs-validation")" novalidate> <form @ref="form" class="@(validated ? "was-validated" : "needs-validation")" novalidate>
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="url" HelpText="A fully qualified Url for this site" ResourceKey="Url">Url:</Label> <Label Class="col-sm-3" For="url" HelpText="A Url identifying a path to a specific page in the site (absolute or relative)" ResourceKey="Url">Url:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="url" class="form-control" @bind="@_url" maxlength="500" readonly /> <input id="url" class="form-control" @bind="@_url" maxlength="500" readonly />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="mappedurl" HelpText="A fully qualified Url where the user will be redirected" ResourceKey="MappedUrl">Redirect To:</Label> <Label Class="col-sm-3" For="mappedurl" HelpText="A Url where the user will be redirected (absolute or relative). Use '/' for site root path." ResourceKey="MappedUrl">Redirect To:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="mappedurl" class="form-control" @bind="@_mappedurl" maxlength="500" required /> <input id="mappedurl" class="form-control" @bind="@_mappedurl" maxlength="500" required />
</div> </div>

View File

@ -18,13 +18,13 @@
<ModuleMessage Message="@_passwordrequirements" Type="MessageType.Info" /> <ModuleMessage Message="@_passwordrequirements" Type="MessageType.Info" />
<div class="container"> <div class="container">
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="username" HelpText="The unique username for a user. Note that this field can not be modified." ResourceKey="Username"></Label> <Label Class="col-sm-3" For="username" HelpText="The unique username for a user. Note that this field can not be modified." ResourceKey="Username">Username:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="username" class="form-control" @bind="@_username" readonly /> <input id="username" class="form-control" @bind="@_username" readonly />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="password" HelpText="The user's password. Please choose a password which is sufficiently secure." ResourceKey="Password"></Label> <Label Class="col-sm-3" For="password" HelpText="The user's password. Please choose a password which is sufficiently secure." ResourceKey="Password">Password:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<div class="input-group"> <div class="input-group">
<input id="password" type="@_passwordtype" class="form-control" @bind="@_password" autocomplete="new-password" /> <input id="password" type="@_passwordtype" class="form-control" @bind="@_password" autocomplete="new-password" />
@ -33,7 +33,7 @@
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="confirm" HelpText="Please enter the password again to confirm it matches with the value above" ResourceKey="Confirm"></Label> <Label Class="col-sm-3" For="confirm" HelpText="Please enter the password again to confirm it matches with the value above" ResourceKey="Confirm">Confirm Password:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<div class="input-group"> <div class="input-group">
<input id="confirm" type="@_passwordtype" class="form-control" @bind="@_confirm" autocomplete="new-password" /> <input id="confirm" type="@_passwordtype" class="form-control" @bind="@_confirm" autocomplete="new-password" />
@ -42,13 +42,22 @@
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="email" HelpText="The email address where the user will receive notifications" ResourceKey="Email"></Label> <Label Class="col-sm-3" For="email" HelpText="The email address where the user will receive notifications" ResourceKey="Email">Email:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="email" class="form-control" @bind="@_email" /> <input id="email" class="form-control" @bind="@_email" />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="displayname" HelpText="The full name of the user" ResourceKey="DisplayName"></Label> <Label Class="col-sm-3" For="confirmed" HelpText="Indicates if the user's email is verified" ResourceKey="Confirmed">Confirmed?</Label>
<div class="col-sm-9">
<select id="confirmed" class="form-select" @bind="@_confirmed">
<option value="True">@SharedLocalizer["Yes"]</option>
<option value="False">@SharedLocalizer["No"]</option>
</select>
</div>
</div>
<div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="displayname" HelpText="The full name of the user" ResourceKey="DisplayName">Full Name:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="displayname" class="form-control" @bind="@_displayname" /> <input id="displayname" class="form-control" @bind="@_displayname" />
</div> </div>
@ -68,7 +77,7 @@
@if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) @if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host))
{ {
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="isdeleted" HelpText="Indicate if the user is active" ResourceKey="IsDeleted"></Label> <Label Class="col-sm-3" For="isdeleted" HelpText="Indicate if the user is active" ResourceKey="IsDeleted">Deleted?</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<select id="isdeleted" class="form-select" @bind="@_isdeleted"> <select id="isdeleted" class="form-select" @bind="@_isdeleted">
<option value="True">@SharedLocalizer["Yes"]</option> <option value="True">@SharedLocalizer["Yes"]</option>
@ -78,13 +87,13 @@
</div> </div>
} }
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="lastlogin" HelpText="The date and time when the user last signed in" ResourceKey="LastLogin"></Label> <Label Class="col-sm-3" For="lastlogin" HelpText="The date and time when the user last signed in" ResourceKey="LastLogin">Last Login:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="lastlogin" class="form-control" @bind="@_lastlogin" readonly /> <input id="lastlogin" class="form-control" @bind="@_lastlogin" readonly />
</div> </div>
</div> </div>
<div class="row mb-1 align-items-center"> <div class="row mb-1 align-items-center">
<Label Class="col-sm-3" For="lastipaddress" HelpText="The IP Address of the user recorded during their last login" ResourceKey="LastIPAddress"></Label> <Label Class="col-sm-3" For="lastipaddress" HelpText="The IP Address of the user recorded during their last login" ResourceKey="LastIPAddress">Last IP Address:</Label>
<div class="col-sm-9"> <div class="col-sm-9">
<input id="lastipaddress" class="form-control" @bind="@_lastipaddress" readonly /> <input id="lastipaddress" class="form-control" @bind="@_lastipaddress" readonly />
</div> </div>
@ -167,6 +176,7 @@
private string _togglepassword = string.Empty; private string _togglepassword = string.Empty;
private string _confirm = string.Empty; private string _confirm = string.Empty;
private string _email = string.Empty; private string _email = string.Empty;
private string _confirmed = string.Empty;
private string _displayname = string.Empty; private string _displayname = string.Empty;
private string _timezoneid = string.Empty; private string _timezoneid = string.Empty;
private string _isdeleted; private string _isdeleted;
@ -204,6 +214,7 @@
{ {
_username = user.Username; _username = user.Username;
_email = user.Email; _email = user.Email;
_confirmed = user.EmailConfirmed.ToString();
_displayname = user.DisplayName; _displayname = user.DisplayName;
_timezoneid = PageState.User.TimeZoneId; _timezoneid = PageState.User.TimeZoneId;
_isdeleted = user.IsDeleted.ToString(); _isdeleted = user.IsDeleted.ToString();
@ -255,6 +266,7 @@
user.Username = _username; user.Username = _username;
user.Password = _password; user.Password = _password;
user.Email = _email; user.Email = _email;
user.EmailConfirmed = bool.Parse(_confirmed);
user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname; user.DisplayName = string.IsNullOrWhiteSpace(_displayname) ? _username : _displayname;
user.TimeZoneId = _timezoneid; user.TimeZoneId = _timezoneid;
if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host)) if (UserSecurity.IsAuthorized(PageState.User, RoleNames.Host))

View File

@ -1,16 +1,16 @@
using Microsoft.AspNetCore.Components;
using Oqtane.Shared;
using Oqtane.Models;
using System.Threading.Tasks;
using Oqtane.Services;
using System; using System;
using Oqtane.Enums;
using Oqtane.UI;
using System.Collections.Generic; using System.Collections.Generic;
using Microsoft.JSInterop;
using System.Linq;
using System.Dynamic; using System.Dynamic;
using System.Reflection; using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using Oqtane.Enums;
using Oqtane.Models;
using Oqtane.Services;
using Oqtane.Shared;
using Oqtane.UI;
namespace Oqtane.Modules namespace Oqtane.Modules
{ {
@ -79,19 +79,22 @@ namespace Oqtane.Modules
{ {
List<Resource> resources = null; List<Resource> resources = null;
var type = GetType(); var type = GetType();
if (type.BaseType == typeof(ModuleBase)) if (type.IsSubclassOf(typeof(ModuleBase)))
{
if (type.IsSubclassOf(typeof(ModuleControlBase)))
{
if (Resources != null)
{
resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList();
}
}
else // ModuleBase
{ {
if (PageState.Page.Resources != null) if (PageState.Page.Resources != null)
{ {
resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Module && item.Namespace == type.Namespace).ToList(); resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Module && item.Namespace == type.Namespace).ToList();
} }
} }
else // modulecontrolbase
{
if (Resources != null)
{
resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList();
}
} }
if (resources != null && resources.Any()) if (resources != null && resources.Any())
{ {
@ -421,70 +424,80 @@ namespace Oqtane.Modules
public string ReplaceTokens(string content, object obj) public string ReplaceTokens(string content, object obj)
{ {
var tokens = new List<string>(); // Using StringBuilder avoids the performance penalty of repeated string allocations
var pos = content.IndexOf("["); // that occur with string.Replace or string concatenation inside loops.
if (pos != -1) var sb = new StringBuilder();
{ var cache = new Dictionary<string, string>(); // Cache to store resolved tokens
if (content.IndexOf("]", pos) != -1) int index = 0;
{
var token = content.Substring(pos, content.IndexOf("]", pos) - pos + 1);
if (token.Contains(":"))
{
tokens.Add(token.Substring(1, token.Length - 2));
}
}
pos = content.IndexOf("[", pos + 1);
}
if (tokens.Count != 0)
{
foreach (string token in tokens)
{
var segments = token.Split(":");
if (segments.Length >= 2 && segments.Length <= 3)
{
var objectName = string.Join(":", segments, 0, segments.Length - 1);
var propertyName = segments[segments.Length - 1];
var propertyValue = "";
switch (objectName) // Loop through content to find and replace all tokens
while (index < content.Length)
{ {
case "ModuleState": int start = content.IndexOf('[', index); // Find start of token
propertyValue = ModuleState.GetType().GetProperty(propertyName)?.GetValue(ModuleState, null).ToString(); if (start == -1)
break;
case "PageState":
propertyValue = PageState.GetType().GetProperty(propertyName)?.GetValue(PageState, null).ToString();
break;
case "PageState:Alias":
propertyValue = PageState.Alias.GetType().GetProperty(propertyName)?.GetValue(PageState.Alias, null).ToString();
break;
case "PageState:Site":
propertyValue = PageState.Site.GetType().GetProperty(propertyName)?.GetValue(PageState.Site, null).ToString();
break;
case "PageState:Page":
propertyValue = PageState.Page.GetType().GetProperty(propertyName)?.GetValue(PageState.Page, null).ToString();
break;
case "PageState:User":
propertyValue = PageState.User?.GetType().GetProperty(propertyName)?.GetValue(PageState.User, null).ToString();
break;
case "PageState:Route":
propertyValue = PageState.Route.GetType().GetProperty(propertyName)?.GetValue(PageState.Route, null).ToString();
break;
default:
if (obj != null && obj.GetType().Name == objectName)
{ {
propertyValue = obj.GetType().GetProperty(propertyName)?.GetValue(obj, null).ToString(); sb.Append(content, index, content.Length - index); // Append remaining content
}
break; break;
} }
if (propertyValue != null)
{
content = content.Replace("[" + token + "]", propertyValue);
}
int end = content.IndexOf(']', start); // Find end of token
if (end == -1)
{
sb.Append(content, index, content.Length - index); // Append unmatched content
break;
}
sb.Append(content, index, start - index); // Append content before token
string token = content.Substring(start + 1, end - start - 1); // Extract token without brackets
string[] parts = token.Split('|', 2); // Separate default fallback if present
string key = parts[0];
string fallback = parts.Length == 2 ? parts[1] : null;
if (!cache.TryGetValue(token, out string replacement)) // Check cache first
{
replacement = "[" + token + "]"; // Default replacement is original token
string[] segments = key.Split(':');
if (segments.Length >= 2)
{
object current = GetTarget(segments[0], obj); // Start from root object
for (int i = 1; i < segments.Length && current != null; i++)
{
var type = current.GetType();
var prop = type.GetProperty(segments[i]);
current = prop?.GetValue(current);
}
if (current != null)
{
replacement = current.ToString();
}
else if (fallback != null)
{
replacement = fallback; // Use fallback if available
} }
} }
cache[token] = replacement; // Store in cache
} }
return content;
sb.Append(replacement); // Append replacement value
index = end + 1; // Move index past token
}
return sb.ToString();
}
// Resolve the object instance for a given object name
// Easy to extend with additional object types
private object GetTarget(string name, object obj)
{
return name switch
{
"ModuleState" => ModuleState,
"PageState" => PageState,
_ => (obj != null && obj.GetType().Name == name) ? obj : null // Fallback to obj
};
} }
// date methods // date methods

View File

@ -121,10 +121,10 @@
<value>Forgot Password</value> <value>Forgot Password</value>
</data> </data>
<data name="Success.Account.Verified" xml:space="preserve"> <data name="Success.Account.Verified" xml:space="preserve">
<value>User Account Verified Successfully. You Can Now Login With Your Username And Password Below.</value> <value>User Account Email Address Verified Successfully. You Can Now Login With Your Username And Password.</value>
</data> </data>
<data name="Message.Account.NotVerified" xml:space="preserve"> <data name="Message.Account.NotVerified" xml:space="preserve">
<value>User Account Could Not Be Verified. Please Contact Your Administrator For Further Instructions.</value> <value>User Account Email Address Could Not Be Verified. Please Contact Your Administrator For Further Instructions.</value>
</data> </data>
<data name="Success.Account.Linked" xml:space="preserve"> <data name="Success.Account.Linked" xml:space="preserve">
<value>User Account Linked Successfully. You Can Now Login With Your External Login Below.</value> <value>User Account Linked Successfully. You Can Now Login With Your External Login Below.</value>
@ -133,7 +133,7 @@
<value>External Login Could Not Be Linked. Please Contact Your Administrator For Further Instructions.</value> <value>External Login Could Not Be Linked. Please Contact Your Administrator For Further Instructions.</value>
</data> </data>
<data name="Error.Login.Fail" xml:space="preserve"> <data name="Error.Login.Fail" xml:space="preserve">
<value>Login Failed. Please Remember That Passwords Are Case Sensitive. If You Have Attempted To Sign In Multiple Times Unsuccessfully, Your Account Will Be Locked Out For A Period Of Time. Note That User Accounts Require Verification When They Are Initially Created So You May Wish To Check Your Email If You Are A New User.</value> <value>Login Failed. Please Remember That Passwords Are Case Sensitive. If You Have Attempted To Sign In Multiple Times Unsuccessfully, Your Account Will Be Locked Out For A Period Of Time. Note That User Accounts Often Require Email Address Verification So You May Wish To Check Your Email For A Notification.</value>
</data> </data>
<data name="Message.Required.UserInfo" xml:space="preserve"> <data name="Message.Required.UserInfo" xml:space="preserve">
<value>Please Provide All Required Fields</value> <value>Please Provide All Required Fields</value>

View File

@ -121,7 +121,7 @@
<value>Export</value> <value>Export</value>
</data> </data>
<data name="Content.HelpText" xml:space="preserve"> <data name="Content.HelpText" xml:space="preserve">
<value>The Exported Module Content</value> <value>Select the Export option and you will be able to view the module content</value>
</data> </data>
<data name="Content.Text" xml:space="preserve"> <data name="Content.Text" xml:space="preserve">
<value>Content: </value> <value>Content: </value>
@ -135,4 +135,25 @@
<data name="Export Content" xml:space="preserve"> <data name="Export Content" xml:space="preserve">
<value>Export Content</value> <value>Export Content</value>
</data> </data>
<data name="Content.Heading" xml:space="preserve">
<value>Content</value>
</data>
<data name="File.Heading" xml:space="preserve">
<value>File</value>
</data>
<data name="Folder.Text" xml:space="preserve">
<value>Folder:</value>
</data>
<data name="Folder.HelpText" xml:space="preserve">
<value>Select a folder where you wish to save the exported content</value>
</data>
<data name="Message.Content.Export" xml:space="preserve">
<value>Please Select A Folder And Provide A Filename Before Choosing Export</value>
</data>
<data name="Filename.Text" xml:space="preserve">
<value>Filename:</value>
</data>
<data name="Filename.HelpText" xml:space="preserve">
<value>Specify a name for the file (without an extension)</value>
</data>
</root> </root>

View File

@ -121,10 +121,10 @@
<value>Redirect To:</value> <value>Redirect To:</value>
</data> </data>
<data name="MappedUrl.HelpText" xml:space="preserve"> <data name="MappedUrl.HelpText" xml:space="preserve">
<value>A relative or absolute Url where the user will be redirected. Use "/" for site root path.</value> <value>A Url where the user will be redirected (absolute or relative). Use '/' for site root path.</value>
</data> </data>
<data name="Url.HelpText" xml:space="preserve"> <data name="Url.HelpText" xml:space="preserve">
<value>An absolute Url for this site</value> <value>A Url identifying a path to a specific page in the site (absolute or relative)</value>
</data> </data>
<data name="Url.Text" xml:space="preserve"> <data name="Url.Text" xml:space="preserve">
<value>Url:</value> <value>Url:</value>

View File

@ -121,10 +121,10 @@
<value>Redirect To:</value> <value>Redirect To:</value>
</data> </data>
<data name="MappedUrl.HelpText" xml:space="preserve"> <data name="MappedUrl.HelpText" xml:space="preserve">
<value>A relative or absolute Url where the user will be redirected. Use "/" for site root path.</value> <value>A Url where the user will be redirected (absolute or relative). Use '/' for site root path.</value>
</data> </data>
<data name="Url.HelpText" xml:space="preserve"> <data name="Url.HelpText" xml:space="preserve">
<value>A relative Url identifying a path to a specific page in the site</value> <value>A Url identifying a path to a specific page in the site (absolute or relative)</value>
</data> </data>
<data name="Url.Text" xml:space="preserve"> <data name="Url.Text" xml:space="preserve">
<value>Url:</value> <value>Url:</value>

View File

@ -216,4 +216,10 @@
<data name="TimeZone.HelpText" xml:space="preserve"> <data name="TimeZone.HelpText" xml:space="preserve">
<value>The user's time zone</value> <value>The user's time zone</value>
</data> </data>
<data name="Confirmed.Text" xml:space="preserve">
<value>Confirmed?</value>
</data>
<data name="Confirmed.HelpText" xml:space="preserve">
<value>Indicates if the user's email is verified</value>
</data>
</root> </root>

View File

@ -56,7 +56,18 @@ namespace Oqtane.Services
/// Exports a given module /// Exports a given module
/// </summary> /// </summary>
/// <param name="moduleId"></param> /// <param name="moduleId"></param>
/// <returns>module in JSON</returns> /// <param name="pageId"></param>
/// <returns>module content in JSON format</returns>
Task<string> ExportModuleAsync(int moduleId, int pageId); Task<string> ExportModuleAsync(int moduleId, int pageId);
/// <summary>
/// Exports a given module
/// </summary>
/// <param name="moduleId"></param>
/// <param name="pageId"></param>
/// <param name="folderId"></param>
/// <param name="filename"></param>
/// <returns>file id</returns>
Task<int> ExportModuleAsync(int moduleId, int pageId, int folderId, string filename);
} }
} }

View File

@ -50,5 +50,10 @@ namespace Oqtane.Services
{ {
return await GetStringAsync($"{Apiurl}/export?moduleid={moduleId}&pageid={pageId}"); return await GetStringAsync($"{Apiurl}/export?moduleid={moduleId}&pageid={pageId}");
} }
public async Task<int> ExportModuleAsync(int moduleId, int pageId, int folderId, string filename)
{
return await PostJsonAsync<string,int>($"{Apiurl}/export?moduleid={moduleId}&pageid={pageId}&folderid={folderId}&filename={filename}", null);
}
} }
} }

View File

@ -43,19 +43,22 @@ namespace Oqtane.Themes
{ {
List<Resource> resources = null; List<Resource> resources = null;
var type = GetType(); var type = GetType();
if (type.BaseType == typeof(ThemeBase)) if (type.IsSubclassOf(typeof(ThemeBase)))
{
if (type.IsSubclassOf(typeof(ThemeControlBase)) || type.IsSubclassOf(typeof(ContainerBase)))
{
if (Resources != null)
{
resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList();
}
}
else // ThemeBase
{ {
if (PageState.Page.Resources != null) if (PageState.Page.Resources != null)
{ {
resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Page && item.Namespace == type.Namespace).ToList(); resources = PageState.Page.Resources.Where(item => item.ResourceType == ResourceType.Script && item.Level == ResourceLevel.Page && item.Namespace == type.Namespace).ToList();
} }
} }
else // themecontrolbase, containerbase
{
if (Resources != null)
{
resources = Resources.Where(item => item.ResourceType == ResourceType.Script).ToList();
}
} }
if (resources != null && resources.Any()) if (resources != null && resources.Any())
{ {

View File

@ -9,6 +9,8 @@ nuget.exe pack Oqtane.Framework.nuspec
del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" > NUL del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" > NUL
rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish" rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish"
dotnet publish ..\Oqtane.Server\Oqtane.Server.csproj /p:Configuration=Release dotnet publish ..\Oqtane.Server\Oqtane.Server.csproj /p:Configuration=Release
del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\Content" > NUL
rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\Content"
del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" > NUL del /F/Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" > NUL
rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content" rmdir /Q/S "..\Oqtane.Server\bin\Release\net9.0\publish\wwwroot\Content"
setlocal ENABLEDELAYEDEXPANSION setlocal ENABLEDELAYEDEXPANSION

View File

@ -22,7 +22,6 @@ using Microsoft.AspNetCore.Cors;
using System.IO.Compression; using System.IO.Compression;
using Oqtane.Services; using Oqtane.Services;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
// ReSharper disable StringIndexOfIsCultureSpecific.1 // ReSharper disable StringIndexOfIsCultureSpecific.1

View File

@ -9,6 +9,7 @@ using Oqtane.Infrastructure;
using Oqtane.Repository; using Oqtane.Repository;
using Oqtane.Security; using Oqtane.Security;
using System.Net; using System.Net;
using System.IO;
namespace Oqtane.Controllers namespace Oqtane.Controllers
{ {
@ -20,18 +21,22 @@ namespace Oqtane.Controllers
private readonly IPageRepository _pages; private readonly IPageRepository _pages;
private readonly IModuleDefinitionRepository _moduleDefinitions; private readonly IModuleDefinitionRepository _moduleDefinitions;
private readonly ISettingRepository _settings; private readonly ISettingRepository _settings;
private readonly IFolderRepository _folders;
private readonly IFileRepository _files;
private readonly IUserPermissions _userPermissions; private readonly IUserPermissions _userPermissions;
private readonly ISyncManager _syncManager; private readonly ISyncManager _syncManager;
private readonly ILogManager _logger; private readonly ILogManager _logger;
private readonly Alias _alias; private readonly Alias _alias;
public ModuleController(IModuleRepository modules, IPageModuleRepository pageModules, IPageRepository pages, IModuleDefinitionRepository moduleDefinitions, ISettingRepository settings, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger) public ModuleController(IModuleRepository modules, IPageModuleRepository pageModules, IPageRepository pages, IModuleDefinitionRepository moduleDefinitions, ISettingRepository settings, IFolderRepository folders, IFileRepository files, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, ILogManager logger)
{ {
_modules = modules; _modules = modules;
_pageModules = pageModules; _pageModules = pageModules;
_pages = pages; _pages = pages;
_moduleDefinitions = moduleDefinitions; _moduleDefinitions = moduleDefinitions;
_settings = settings; _settings = settings;
_folders = folders;
_files = files;
_userPermissions = userPermissions; _userPermissions = userPermissions;
_syncManager = syncManager; _syncManager = syncManager;
_logger = logger; _logger = logger;
@ -248,6 +253,61 @@ namespace Oqtane.Controllers
return content; return content;
} }
// POST api/<controller>/export?moduleid=x&pageid=y&folderid=z&filename=a
[HttpPost("export")]
[Authorize(Roles = RoleNames.Registered)]
public int Export(int moduleid, int pageid, int folderid, string filename)
{
var fileid = -1;
var module = _modules.GetModule(moduleid);
if (module != null && module.SiteId == _alias.SiteId && _userPermissions.IsAuthorized(User, module.SiteId, EntityNames.Page, pageid, PermissionNames.Edit) &&
_userPermissions.IsAuthorized(User, module.SiteId, EntityNames.Folder, folderid, PermissionNames.Edit) && !string.IsNullOrEmpty(filename))
{
// get content
var content = _modules.ExportModule(moduleid);
// get folder
var folder = _folders.GetFolder(folderid, false);
string folderPath = _folders.GetFolderPath(folder);
if (!Directory.Exists(folderPath))
{
Directory.CreateDirectory(folderPath);
}
// create json file
filename = Utilities.GetFriendlyUrl(Path.GetFileNameWithoutExtension(filename)) + ".json";
string filepath = Path.Combine(folderPath, filename);
if (System.IO.File.Exists(filepath))
{
System.IO.File.Delete(filepath);
}
System.IO.File.WriteAllText(filepath, content);
// register file
var file = _files.GetFile(folderid, filename);
if (file == null)
{
file = new Models.File { FolderId = folderid, Name = filename, Extension = "json", Size = (int)new FileInfo(filepath).Length, ImageWidth = 0, ImageHeight = 0 };
_files.AddFile(file);
}
else
{
file.Size = (int)new FileInfo(filepath).Length;
_files.UpdateFile(file);
}
fileid = file.FileId;
_logger.Log(LogLevel.Information, this, LogFunction.Read, "Content Exported For Module {ModuleId} To Folder {FolderId}", moduleid, folderid);
}
else
{
_logger.Log(LogLevel.Error, this, LogFunction.Security, "Unauthorized Export Attempt For Module {Module} To Folder {FolderId}", moduleid, folderid);
HttpContext.Response.StatusCode = (int)HttpStatusCode.Forbidden;
}
return fileid;
}
// POST api/<controller>/import?moduleid=x&pageid=y // POST api/<controller>/import?moduleid=x&pageid=y
[HttpPost("import")] [HttpPost("import")]
[Authorize(Roles = RoleNames.Registered)] [Authorize(Roles = RoleNames.Registered)]

View File

@ -24,26 +24,50 @@ namespace Oqtane.Controllers
private readonly IPageModuleRepository _pageModules; private readonly IPageModuleRepository _pageModules;
private readonly IUserPermissions _userPermissions; private readonly IUserPermissions _userPermissions;
private readonly ISyncManager _syncManager; private readonly ISyncManager _syncManager;
private readonly IAliasAccessor _aliasAccessor;
private readonly IOptionsMonitorCache<CookieAuthenticationOptions> _cookieCache; private readonly IOptions<CookieAuthenticationOptions> _cookieOptions;
private readonly IOptionsMonitorCache<OpenIdConnectOptions> _oidcCache; private readonly IOptionsSnapshot<CookieAuthenticationOptions> _cookieOptionsSnapshot;
private readonly IOptionsMonitorCache<OAuthOptions> _oauthCache; private readonly IOptionsMonitorCache<CookieAuthenticationOptions> _cookieOptionsMonitorCache;
private readonly IOptionsMonitorCache<IdentityOptions> _identityCache;
private readonly IOptions<OpenIdConnectOptions> _oidcOptions;
private readonly IOptionsSnapshot<OpenIdConnectOptions> _oidcOptionsSnapshot;
private readonly IOptionsMonitorCache<OpenIdConnectOptions> _oidcOptionsMonitorCache;
private readonly IOptions<OAuthOptions> _oauthOptions;
private readonly IOptionsSnapshot<OAuthOptions> _oauthOptionsSnapshot;
private readonly IOptionsMonitorCache<OAuthOptions> _oauthOptionsMonitorCache;
private readonly IOptions<IdentityOptions> _identityOptions;
private readonly IOptionsSnapshot<IdentityOptions> _identityOptionsSnapshot;
private readonly IOptionsMonitorCache<IdentityOptions> _identityOptionsMonitorCache;
private readonly ILogManager _logger; private readonly ILogManager _logger;
private readonly Alias _alias; private readonly Alias _alias;
private readonly string _visitorCookie; private readonly string _visitorCookie;
public SettingController(ISettingRepository settings, IPageModuleRepository pageModules, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager, IAliasAccessor aliasAccessor, IOptionsMonitorCache<CookieAuthenticationOptions> cookieCache, IOptionsMonitorCache<OpenIdConnectOptions> oidcCache, IOptionsMonitorCache<OAuthOptions> oauthCache, IOptionsMonitorCache<IdentityOptions> identityCache, ILogManager logger) public SettingController(ISettingRepository settings, IPageModuleRepository pageModules, IUserPermissions userPermissions, ITenantManager tenantManager, ISyncManager syncManager,
IOptions<CookieAuthenticationOptions> cookieOptions, IOptionsSnapshot<CookieAuthenticationOptions> cookieOptionsSnapshot, IOptionsMonitorCache<CookieAuthenticationOptions> cookieOptionsMonitorCache,
IOptions<OpenIdConnectOptions> oidcOptions, IOptionsSnapshot<OpenIdConnectOptions> oidcOptionsSnapshot, IOptionsMonitorCache<OpenIdConnectOptions> oidcOptionsMonitorCache,
IOptions<OAuthOptions> oauthOptions, IOptionsSnapshot<OAuthOptions> oauthOptionsSnapshot, IOptionsMonitorCache<OAuthOptions> oauthOptionsMonitorCache,
IOptions<IdentityOptions> identityOptions, IOptionsSnapshot<IdentityOptions> identityOptionsSnapshot, IOptionsMonitorCache<IdentityOptions> identityOptionsMonitorCache,
ILogManager logger)
{ {
_settings = settings; _settings = settings;
_pageModules = pageModules; _pageModules = pageModules;
_userPermissions = userPermissions; _userPermissions = userPermissions;
_syncManager = syncManager; _syncManager = syncManager;
_aliasAccessor = aliasAccessor; _cookieOptions = cookieOptions;
_cookieCache = cookieCache; _cookieOptionsSnapshot = cookieOptionsSnapshot;
_oidcCache = oidcCache; _cookieOptionsMonitorCache = cookieOptionsMonitorCache;
_oauthCache = oauthCache; _oidcOptions = oidcOptions;
_identityCache = identityCache; _oidcOptionsSnapshot = oidcOptionsSnapshot;
_oidcOptionsMonitorCache = oidcOptionsMonitorCache;
_oauthOptions = oauthOptions;
_oauthOptionsSnapshot = oauthOptionsSnapshot;
_oauthOptionsMonitorCache = oauthOptionsMonitorCache;
_identityOptions = identityOptions;
_identityOptionsSnapshot = identityOptionsSnapshot;
_identityOptionsMonitorCache = identityOptionsMonitorCache;
_logger = logger; _logger = logger;
_alias = tenantManager.GetAlias(); _alias = tenantManager.GetAlias();
_visitorCookie = Constants.VisitorCookiePrefix + _alias.SiteId.ToString(); _visitorCookie = Constants.VisitorCookiePrefix + _alias.SiteId.ToString();
@ -210,21 +234,21 @@ namespace Oqtane.Controllers
[Authorize(Roles = RoleNames.Admin)] [Authorize(Roles = RoleNames.Admin)]
public void Clear() public void Clear()
{ {
// clear SiteOptionsCache for each option type (_cookieOptions as SiteOptionsManager<CookieAuthenticationOptions>).Reset();
var cookieCache = new SiteOptionsCache<CookieAuthenticationOptions>(_aliasAccessor); (_cookieOptionsSnapshot as SiteOptionsManager<CookieAuthenticationOptions>).Reset();
cookieCache.Clear(); _cookieOptionsMonitorCache.Clear();
var oidcCache = new SiteOptionsCache<OpenIdConnectOptions>(_aliasAccessor);
oidcCache.Clear();
var oauthCache = new SiteOptionsCache<OAuthOptions>(_aliasAccessor);
oauthCache.Clear();
var identityCache = new SiteOptionsCache<IdentityOptions>(_aliasAccessor);
identityCache.Clear();
// clear IOptionsMonitorCache for each option type (_oidcOptions as SiteOptionsManager<OpenIdConnectOptions>).Reset();
_cookieCache.Clear(); (_oidcOptionsSnapshot as SiteOptionsManager<OpenIdConnectOptions>).Reset();
_oidcCache.Clear(); _oidcOptionsMonitorCache.Clear();
_oauthCache.Clear();
_identityCache.Clear(); (_oauthOptions as SiteOptionsManager<OAuthOptions>).Reset();
(_oauthOptionsSnapshot as SiteOptionsManager<OAuthOptions>).Reset();
_oauthOptionsMonitorCache.Clear();
(_identityOptions as SiteOptionsManager<IdentityOptions>).Reset();
(_identityOptionsSnapshot as SiteOptionsManager<IdentityOptions>).Reset();
_identityOptionsMonitorCache.Clear();
_logger.Log(LogLevel.Information, this, LogFunction.Other, "Site Options Cache Cleared"); _logger.Log(LogLevel.Information, this, LogFunction.Other, "Site Options Cache Cleared");
} }

View File

@ -131,7 +131,7 @@ namespace Oqtane.Controllers
filtered.TwoFactorCode = ""; filtered.TwoFactorCode = "";
filtered.SecurityStamp = ""; filtered.SecurityStamp = "";
// include private properties if authenticated user is accessing their own user account os is an administrator // include private properties if authenticated user is accessing their own user account or is an administrator
if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == user.UserId) if (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || _userPermissions.GetUser(User).UserId == user.UserId)
{ {
filtered.Email = user.Email; filtered.Email = user.Email;
@ -140,6 +140,7 @@ namespace Oqtane.Controllers
filtered.LastLoginOn = user.LastLoginOn; filtered.LastLoginOn = user.LastLoginOn;
filtered.LastIPAddress = user.LastIPAddress; filtered.LastIPAddress = user.LastIPAddress;
filtered.TwoFactorRequired = user.TwoFactorRequired; filtered.TwoFactorRequired = user.TwoFactorRequired;
filtered.EmailConfirmed = user.EmailConfirmed;
filtered.Roles = user.Roles; filtered.Roles = user.Roles;
filtered.CreatedBy = user.CreatedBy; filtered.CreatedBy = user.CreatedBy;
filtered.CreatedOn = user.CreatedOn; filtered.CreatedOn = user.CreatedOn;
@ -200,10 +201,15 @@ namespace Oqtane.Controllers
[Authorize] [Authorize]
public async Task<User> Put(int id, [FromBody] User user) public async Task<User> Put(int id, [FromBody] User user)
{ {
if (ModelState.IsValid && user.SiteId == _tenantManager.GetAlias().SiteId && user.UserId == id && _users.GetUser(user.UserId, false) != null var existing = _userManager.GetUser(user.UserId, user.SiteId);
if (ModelState.IsValid && user.SiteId == _tenantManager.GetAlias().SiteId && user.UserId == id && existing != null
&& (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || User.Identity.Name == user.Username)) && (_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin) || User.Identity.Name == user.Username))
{ {
user.EmailConfirmed = User.IsInRole(RoleNames.Admin); // only authorized users can update the email confirmation
if (!_userPermissions.IsAuthorized(User, user.SiteId, EntityNames.User, -1, PermissionNames.Write, RoleNames.Admin))
{
user.EmailConfirmed = existing.EmailConfirmed;
}
user = await _userManager.UpdateUser(user); user = await _userManager.UpdateUser(user);
} }
else else

View File

@ -1,5 +1,4 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Oqtane.Models;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using System; using System;

View File

@ -19,7 +19,6 @@ namespace Oqtane.Infrastructure
{ {
var cache = map.GetOrAdd(GetKey(), new OptionsCache<TOptions>()); var cache = map.GetOrAdd(GetKey(), new OptionsCache<TOptions>());
cache.Clear(); cache.Clear();
} }
public TOptions GetOrAdd(string name, Func<TOptions> createOptions) public TOptions GetOrAdd(string name, Func<TOptions> createOptions)

View File

@ -65,7 +65,12 @@ namespace Oqtane.Managers
{ {
user.SiteId = siteid; user.SiteId = siteid;
user.Roles = GetUserRoles(user.UserId, user.SiteId); user.Roles = GetUserRoles(user.UserId, user.SiteId);
user.SecurityStamp = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult()?.SecurityStamp; var identityuser = _identityUserManager.FindByNameAsync(user.Username).GetAwaiter().GetResult();
if (identityuser != null)
{
user.SecurityStamp = identityuser.SecurityStamp;
user.EmailConfirmed = identityuser.EmailConfirmed;
}
user.Settings = _settings.GetSettings(EntityNames.User, user.UserId) user.Settings = _settings.GetSettings(EntityNames.User, user.UserId)
.ToDictionary(setting => setting.SettingName, setting => setting.SettingValue); .ToDictionary(setting => setting.SettingName, setting => setting.SettingValue);
} }
@ -245,23 +250,31 @@ namespace Oqtane.Managers
{ {
identityuser.Email = user.Email; identityuser.Email = user.Email;
await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated
}
// if email address changed and it is not confirmed, verification is required for new email address if (user.EmailConfirmed)
if (!user.EmailConfirmed)
{ {
if (!identityuser.EmailConfirmed)
{
var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken);
string body = "Dear " + user.DisplayName + ",\n\nThe Email Address For Your User Account Has Been Verified. You Can Now Login With Your Username And Password.";
var notification = new Notification(user.SiteId, user, "User Account Verification", body);
_notifications.AddNotification(notification);
}
}
else
{
identityuser.EmailConfirmed = false;
await _identityUserManager.UpdateAsync(identityuser); // security stamp not updated
string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser); string token = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token); string url = alias.Protocol + alias.Name + "/login?name=" + user.Username + "&token=" + WebUtility.UrlEncode(token);
string body = "Dear " + user.DisplayName + ",\n\nIn Order To Verify The Email Address Associated To Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!"; string body = "Dear " + user.DisplayName + ",\n\nIn Order To Verify The Email Address Associated To Your User Account Please Click The Link Displayed Below:\n\n" + url + "\n\nThank You!";
var notification = new Notification(user.SiteId, user, "User Account Verification", body); var notification = new Notification(user.SiteId, user, "User Account Verification", body);
_notifications.AddNotification(notification); _notifications.AddNotification(notification);
} }
}
if (user.EmailConfirmed)
{
var emailConfirmationToken = await _identityUserManager.GenerateEmailConfirmationTokenAsync(identityuser);
await _identityUserManager.ConfirmEmailAsync(identityuser, emailConfirmationToken);
}
user = _users.UpdateUser(user); user = _users.UpdateUser(user);
_syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Update); _syncManager.AddSyncEvent(_tenantManager.GetAlias(), EntityNames.User, user.UserId, SyncEventActions.Update);

View File

@ -48,7 +48,7 @@
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.11" /> <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.11" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="HtmlAgilityPack" Version="1.12.1" /> <PackageReference Include="HtmlAgilityPack" Version="1.12.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Oqtane.Client\Oqtane.Client.csproj" /> <ProjectReference Include="..\Oqtane.Client\Oqtane.Client.csproj" />

View File

@ -43,7 +43,7 @@ namespace Oqtane.Models
public string Path { get; set; } public string Path { get; set; }
/// <summary> /// <summary>
/// Sorting order of the folder /// Sorting order of the folder ** not used as folders are sorted in alphabetical order **
/// </summary> /// </summary>
public int Order { get; set; } public int Order { get; set; }

View File

@ -6,6 +6,8 @@ namespace Oqtane.Models
public string Message { get; set; } public string Message { get; set; }
public Result() {}
public Result(bool success) public Result(bool success)
{ {
Success = success; Success = success;

View File

@ -12,7 +12,7 @@ Oqtane is being developed based on some fundamental principles which are outline
# Latest Release # Latest Release
[6.1.2](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.2) was released on April 10, 2025 and is a maintenance release including 41 pull requests by 3 different contributors, pushing the total number of project commits all-time to over 6500. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers. [6.1.3](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.3) was released on May 29, 2025 and is a maintenance release including 59 pull requests by 5 different contributors, pushing the total number of project commits all-time to over 6600. The Oqtane framework continues to evolve at a rapid pace to meet the needs of .NET developers.
# Try It Now! # Try It Now!
@ -22,11 +22,11 @@ Microsoft's Public Cloud (requires an Azure account)
A free ASP.NET hosting account. No hidden fees. No credit card required. A free ASP.NET hosting account. No hidden fees. No credit card required.
[![Deploy to MonsterASP.NET](https://www.oqtane.org/files/Public/MonsterASPNET.png)](https://www.monsterasp.net/) [![Deploy to MonsterASP.NET](https://www.oqtane.org/files/Public/MonsterASPNET.png)](https://www.monsterasp.net/)
# Getting Started (Version 6.1.2) # Getting Started (Version 6)
**Installing using source code from the Dev/Master branch:** **Installing using source code from the Dev/Master branch:**
- Install **[.NET 9.0.4 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)**. - Install **[.NET 9.0.5 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)**.
- Install the latest edition (v17.12 or higher) of [Visual Studio 2022](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**. - Install the latest edition (v17.12 or higher) of [Visual Studio 2022](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**.
@ -92,6 +92,9 @@ Connect with other developers, get support, and share ideas by joining the Oqtan
# Roadmap # Roadmap
This project is open source, and therefore is a work in progress... This project is open source, and therefore is a work in progress...
[6.1.3](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.3) (May 29, 2025)
- [x] Stabilization improvements
[6.1.2](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.2) (Apr 10, 2025) [6.1.2](https://github.com/oqtane/oqtane.framework/releases/tag/v6.1.2) (Apr 10, 2025)
- [x] Stabilization improvements - [x] Stabilization improvements

View File

@ -220,7 +220,7 @@
"apiVersion": "2024-04-01", "apiVersion": "2024-04-01",
"name": "[concat(parameters('BlazorWebsiteName'), '/ZipDeploy')]", "name": "[concat(parameters('BlazorWebsiteName'), '/ZipDeploy')]",
"properties": { "properties": {
"packageUri": "https://github.com/oqtane/oqtane.framework/releases/download/v6.1.2/Oqtane.Framework.6.1.2.Install.zip" "packageUri": "https://github.com/oqtane/oqtane.framework/releases/download/v6.1.3/Oqtane.Framework.6.1.3.Install.zip"
}, },
"dependsOn": [ "dependsOn": [
"[resourceId('Microsoft.Web/sites', parameters('BlazorWebsiteName'))]" "[resourceId('Microsoft.Web/sites', parameters('BlazorWebsiteName'))]"