mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-08-30 23:00:06 -04:00
Merge branch 'develop' into bugfix/side-nav-issue
This commit is contained in:
commit
47216a05c4
22
API.Tests/Extensions/EnumExtensionTests.cs
Normal file
22
API.Tests/Extensions/EnumExtensionTests.cs
Normal file
@ -0,0 +1,22 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Extensions;
|
||||
|
||||
public class EnumExtensionTests
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("Early Childhood", AgeRating.EarlyChildhood, true)]
|
||||
[InlineData("M", AgeRating.Mature, true)]
|
||||
[InlineData("ThisIsNotAnAgeRating", default(AgeRating), false)]
|
||||
public void TryParse<TEnum>(string? value, TEnum expected, bool success) where TEnum : struct, Enum
|
||||
{
|
||||
Assert.Equal(EnumExtensions.TryParse(value, out TEnum got), success);
|
||||
Assert.Equal(expected, got);
|
||||
}
|
||||
|
||||
}
|
298
API.Tests/Services/AccountServiceTests.cs
Normal file
298
API.Tests/Services/AccountServiceTests.cs
Normal file
@ -0,0 +1,298 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class AccountServiceTests: AbstractDbTest
|
||||
{
|
||||
|
||||
[Theory]
|
||||
[InlineData("admin", true)]
|
||||
[InlineData("^^$SomeBadChars", false)]
|
||||
[InlineData("Lisa2003", true)]
|
||||
[InlineData("Kraft Lawrance", false)]
|
||||
public async Task ValidateUsername_Regex(string username, bool valid)
|
||||
{
|
||||
await ResetDb();
|
||||
var (_, accountService, _, _) = await Setup();
|
||||
|
||||
Assert.Equal(valid, !(await accountService.ValidateUsername(username)).Any());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Throws_WhenDefaultAdminUser()
|
||||
{
|
||||
await ResetDb();
|
||||
var (_, accountService, _, _) = await Setup();
|
||||
|
||||
var defaultAdmin = await UnitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(() =>
|
||||
accountService.ChangeIdentityProvider(defaultAdmin.Id, defaultAdmin, IdentityProvider.Kavita));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsIsFalse()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita);
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Throws_WhenUserIsOidcManaged_AndNoChange()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, settingsService) = await Setup();
|
||||
|
||||
user.IdentityProvider = IdentityProvider.OpenIdConnect;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
settings.OidcConfig.SyncUserSettings = true;
|
||||
await settingsService.UpdateSettings(settings);
|
||||
|
||||
await Assert.ThrowsAsync<KavitaException>(() =>
|
||||
accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_Succeeds_WhenSyncUserSettingsTrue_AndChangeIsAllowed()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, settingsService) = await Setup();
|
||||
|
||||
user.IdentityProvider = IdentityProvider.OpenIdConnect;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
settings.OidcConfig.SyncUserSettings = true;
|
||||
await settingsService.UpdateSettings(settings);
|
||||
|
||||
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.Kavita);
|
||||
|
||||
Assert.False(result);
|
||||
|
||||
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(IdentityProvider.Kavita, updated.IdentityProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangeIdentityProvider_ReturnsTrue_WhenChangedToOidc()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, settingsService) = await Setup();
|
||||
|
||||
user.IdentityProvider = IdentityProvider.Kavita;
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var settings = await UnitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
settings.OidcConfig.SyncUserSettings = true;
|
||||
await settingsService.UpdateSettings(settings);
|
||||
|
||||
var result = await accountService.ChangeIdentityProvider(user.Id, user, IdentityProvider.OpenIdConnect);
|
||||
|
||||
Assert.True(result);
|
||||
|
||||
var updated = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(IdentityProvider.OpenIdConnect, updated.IdentityProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_GrantsAccessToAllLibraries_WhenAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int>(), hasAdminRole: true);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id);
|
||||
Assert.Equal(2, userLibs.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_GrantsAccessToSelectedLibraries_WhenNotAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Single(userLibs);
|
||||
Assert.Equal(mangaLib.Id, userLibs.First().Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_RemovesAccessFromUnselectedLibraries_WhenNotAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Grant access to both libraries
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Equal(2, userLibs.Count);
|
||||
|
||||
// Now restrict access to only light novels
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { lightNovelsLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Single(userLibs);
|
||||
Assert.Equal(lightNovelsLib.Id, userLibs.First().Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateLibrariesForUser_GrantsNoLibraries_WhenNoneSelected_AndNotAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (user, accountService, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
// Initially grant access to both libraries
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int> { mangaLib.Id, lightNovelsLib.Id }, hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Equal(2, userLibs.Count);
|
||||
|
||||
// Now revoke all access by passing empty list
|
||||
await accountService.UpdateLibrariesForUser(user, new List<int>(), hasAdminRole: false);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
userLibs = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
|
||||
Assert.Empty(userLibs);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private async Task<(AppUser, IAccountService, UserManager<AppUser>, SettingsService)> Setup()
|
||||
{
|
||||
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
|
||||
.WithRole(PolicyConstants.AdminRole)
|
||||
.Build();
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
|
||||
|
||||
var roleStore = new RoleStore<
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserRole<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
|
||||
var roleManager = new RoleManager<AppRole>(
|
||||
roleStore,
|
||||
[new RoleValidator<AppRole>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
Substitute.For<ILogger<RoleManager<AppRole>>>());
|
||||
|
||||
foreach (var role in PolicyConstants.ValidRoles)
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
{
|
||||
await roleManager.CreateAsync(new AppRole
|
||||
{
|
||||
Name = role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var userStore = new UserStore<
|
||||
AppUser,
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserClaim<int>,
|
||||
AppUserRole,
|
||||
IdentityUserLogin<int>,
|
||||
IdentityUserToken<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
var userManager = new UserManager<AppUser>(userStore,
|
||||
new OptionsWrapper<IdentityOptions>(new IdentityOptions()),
|
||||
new PasswordHasher<AppUser>(),
|
||||
[new UserValidator<AppUser>()],
|
||||
[new PasswordValidator<AppUser>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
Substitute.For<ILogger<UserManager<AppUser>>>());
|
||||
|
||||
// Create users with the UserManager such that the SecurityStamp is set
|
||||
await userManager.CreateAsync(user);
|
||||
await userManager.CreateAsync(defaultAdmin);
|
||||
|
||||
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), UnitOfWork, Mapper, Substitute.For<ILocalizationService>());
|
||||
var settingsService = new SettingsService(UnitOfWork, Substitute.For<IDirectoryService>(), Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SettingsService>> (), Substitute.For<IOidcService>());
|
||||
|
||||
user = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id, AppUserIncludes.SideNavStreams);
|
||||
return (user, accountService, userManager, settingsService);
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
Context.AppUser.RemoveRange(Context.AppUser);
|
||||
Context.Library.RemoveRange(Context.Library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
@ -1288,6 +1288,21 @@ public class ExternalMetadataServiceTests : AbstractDbTest
|
||||
Assert.Equal(AgeRating.Teen, postSeries.Metadata.AgeRating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AgeRating_NormalizedMapping()
|
||||
{
|
||||
var tags = new List<string> { "tAg$'1", "tag2" };
|
||||
var mappings = new Dictionary<string, AgeRating>()
|
||||
{
|
||||
["tag1"] = AgeRating.Teen,
|
||||
};
|
||||
|
||||
Assert.Equal(AgeRating.Teen, ExternalMetadataService.DetermineAgeRating(tags, mappings));
|
||||
|
||||
mappings.Add("tag2", AgeRating.AdultsOnly);
|
||||
Assert.Equal(AgeRating.AdultsOnly, ExternalMetadataService.DetermineAgeRating(tags, mappings));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Genres
|
||||
@ -1600,6 +1615,100 @@ public class ExternalMetadataServiceTests : AbstractDbTest
|
||||
Assert.Equal(["Boxing"], postSeries.Metadata.Tags.Select(t => t.Title));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region FieldMappings
|
||||
|
||||
[Fact]
|
||||
public void GenerateGenreAndTagLists_Normalized_Mappings()
|
||||
{
|
||||
var settings = new MetadataSettingsDto
|
||||
{
|
||||
EnableExtendedMetadataProcessing = true,
|
||||
Whitelist = [],
|
||||
Blacklist = [],
|
||||
FieldMappings = [
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
SourceType = MetadataFieldType.Tag,
|
||||
SourceValue = "Girls love",
|
||||
DestinationType = MetadataFieldType.Genre,
|
||||
DestinationValue = "Yuri",
|
||||
ExcludeFromSource = false,
|
||||
},
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
SourceType = MetadataFieldType.Tag,
|
||||
SourceValue = "Girls love",
|
||||
DestinationType = MetadataFieldType.Genre,
|
||||
DestinationValue = "Romance",
|
||||
ExcludeFromSource = false,
|
||||
},
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
SourceType = MetadataFieldType.Genre,
|
||||
SourceValue = "WW2",
|
||||
DestinationType = MetadataFieldType.Genre,
|
||||
DestinationValue = "War",
|
||||
ExcludeFromSource = true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var tags = new List<string> { "Girl's Love", "Unrelated tag" };
|
||||
var genres = new List<string> { "Ww2", "Unrelated genre" };
|
||||
|
||||
ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings,
|
||||
out var finalTags, out var finalGenres);
|
||||
|
||||
Assert.Contains("Unrelated tag", finalTags);
|
||||
|
||||
Assert.Contains("Yuri", finalGenres);
|
||||
Assert.Contains("Romance", finalGenres);
|
||||
Assert.Contains("Unrelated genre", finalGenres);
|
||||
Assert.DoesNotContain("Ww2", finalGenres);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateGenreAndTagLists_RemoveIfAnyRemoves()
|
||||
{
|
||||
var settings = new MetadataSettingsDto
|
||||
{
|
||||
EnableExtendedMetadataProcessing = true,
|
||||
Whitelist = [],
|
||||
Blacklist = [],
|
||||
FieldMappings = [
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
SourceType = MetadataFieldType.Tag,
|
||||
SourceValue = "Girls love",
|
||||
DestinationType = MetadataFieldType.Genre,
|
||||
DestinationValue = "Yuri",
|
||||
ExcludeFromSource = false,
|
||||
},
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
SourceType = MetadataFieldType.Tag,
|
||||
SourceValue = "Girls love",
|
||||
DestinationType = MetadataFieldType.Genre,
|
||||
DestinationValue = "Romance",
|
||||
ExcludeFromSource = true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
var tags = new List<string> { "Girl's Love"};
|
||||
var genres = new List<string>();
|
||||
|
||||
ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings,
|
||||
out var finalTags, out var finalGenres);
|
||||
|
||||
Assert.Contains("Yuri", finalGenres);
|
||||
Assert.Contains("Romance", finalGenres);
|
||||
Assert.DoesNotContain("Girls Love", finalGenres);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region People - Writers/Artists
|
||||
|
582
API.Tests/Services/OidcServiceTests.cs
Normal file
582
API.Tests/Services/OidcServiceTests.cs
Normal file
@ -0,0 +1,582 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace API.Tests.Services;
|
||||
|
||||
public class OidcServiceTests: AbstractDbTest
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public async Task UserSync_Username()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, _, _, userManager) = await Setup();
|
||||
|
||||
var user = new AppUserBuilder("holo", "holo@localhost").Build();
|
||||
var res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Name, "amelia"),
|
||||
new (ClaimTypes.GivenName, "Lawrence"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
// name is updated as the current username is not found, amelia is skipped as it is alredy in use
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal("Lawrence", user.UserName);
|
||||
|
||||
claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Name, "amelia"),
|
||||
new (ClaimTypes.GivenName, "Lawrence"),
|
||||
new (ClaimTypes.Surname, "Norah"),
|
||||
};
|
||||
identity = new ClaimsIdentity(claims);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
// Ensure a name longer down the list isn't picked if the current username is found
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal("Lawrence", user.UserName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserSync_CustomClaim()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
const string claim = "groups";
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (claim, PolicyConstants.LoginRole),
|
||||
new (claim, PolicyConstants.DownloadRole),
|
||||
new (ClaimTypes.Role, PolicyConstants.PromoteRole),
|
||||
new (claim, OidcService.AgeRestrictionPrefix + "M"),
|
||||
new (claim, OidcService.LibraryAccessPrefix + "Manga"),
|
||||
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
RolesClaim = claim,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
// Check correct roles assigned
|
||||
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
|
||||
Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles);
|
||||
|
||||
// Check correct libraries
|
||||
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(mangaLib.Name, libraries);
|
||||
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
|
||||
|
||||
// Check correct age restrictions
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
Assert.False(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UserSync_CustomPrefix()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
const string prefix = "kavita-";
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, prefix + PolicyConstants.LoginRole),
|
||||
new (ClaimTypes.Role, prefix + PolicyConstants.DownloadRole),
|
||||
new (ClaimTypes.Role, PolicyConstants.PromoteRole),
|
||||
new (ClaimTypes.Role, prefix + OidcService.AgeRestrictionPrefix + "M"),
|
||||
new (ClaimTypes.Role, prefix + OidcService.LibraryAccessPrefix + "Manga"),
|
||||
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
RolesPrefix = prefix,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
// Check correct roles assigned
|
||||
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
|
||||
Assert.DoesNotContain(PolicyConstants.PromoteRole, userRoles);
|
||||
|
||||
// Check correct libraries
|
||||
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(mangaLib.Name, libraries);
|
||||
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
|
||||
|
||||
// Check correct age restrictions
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
Assert.False(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncRoles()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, PolicyConstants.LoginRole),
|
||||
new (ClaimTypes.Role, PolicyConstants.DownloadRole),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.Contains(PolicyConstants.DownloadRole, userRoles);
|
||||
|
||||
// Only give one role
|
||||
claims = [new Claim(ClaimTypes.Role, PolicyConstants.LoginRole)];
|
||||
identity = new ClaimsIdentity(claims);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
userRoles = await UnitOfWork.UserRepository.GetRoles(user.Id);
|
||||
Assert.Contains(PolicyConstants.LoginRole, userRoles);
|
||||
Assert.DoesNotContain(PolicyConstants.DownloadRole, userRoles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncLibraries()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var mangaLib = new LibraryBuilder("Manga", LibraryType.Manga).Build();
|
||||
var lightNovelsLib = new LibraryBuilder("Light Novels", LibraryType.LightNovel).Build();
|
||||
|
||||
UnitOfWork.LibraryRepository.Add(mangaLib);
|
||||
UnitOfWork.LibraryRepository.Add(lightNovelsLib);
|
||||
await UnitOfWork.CommitAsync();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Manga"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(mangaLib.Name, libraries);
|
||||
Assert.DoesNotContain(lightNovelsLib.Name, libraries);
|
||||
|
||||
// Only give access to the other library
|
||||
claims = [new Claim(ClaimTypes.Role, OidcService.LibraryAccessPrefix + "Light Novels")];
|
||||
identity = new ClaimsIdentity(claims);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
// Check access has swicthed
|
||||
libraries = (await UnitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).Select(l => l.Name).ToList();
|
||||
Assert.Single(libraries);
|
||||
Assert.Contains(lightNovelsLib.Name, libraries);
|
||||
Assert.DoesNotContain(mangaLib.Name, libraries);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestrictions_NoRestrictions()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Not Applicable"),
|
||||
new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestrictions_IncludeUnknowns()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
|
||||
new(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestriction_AdminNone()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, PolicyConstants.AdminRole),
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestriction_MultipleAgeRestrictionClaims()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"),
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "M"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.Mature, dbUser.AgeRestriction);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncAgeRestriction_NoAgeRestrictionClaims()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, user, _, _) = await Setup();
|
||||
|
||||
var identity = new ClaimsIdentity([]);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
|
||||
// Also default to no restrictions when only include unknowns is present
|
||||
identity = new ClaimsIdentity([new Claim(ClaimTypes.Role, OidcService.AgeRestrictionPrefix + OidcService.IncludeUnknowns)]);
|
||||
principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
dbUser = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(dbUser);
|
||||
Assert.Equal(AgeRating.NotApplicable, dbUser.AgeRestriction);
|
||||
Assert.True(dbUser.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SyncUserSettings_DontChangeDefaultAdmin()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, _, _, userManager) = await Setup();
|
||||
|
||||
// Make user default user
|
||||
var user = await UnitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
|
||||
var settings = new OidcConfigDto
|
||||
{
|
||||
SyncUserSettings = true,
|
||||
};
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new (ClaimTypes.Role, PolicyConstants.ChangePasswordRole),
|
||||
new (ClaimTypes.Role, OidcService.AgeRestrictionPrefix + "Teen"),
|
||||
};
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, user);
|
||||
|
||||
var userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(user.Id);
|
||||
Assert.NotNull(userFromDb);
|
||||
Assert.NotEqual(AgeRating.Teen, userFromDb.AgeRestriction);
|
||||
|
||||
var newUser = new AppUserBuilder("NotAnAdmin", "NotAnAdmin@localhost").Build();
|
||||
var res = await userManager.CreateAsync(newUser);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
await oidcService.SyncUserSettings(null!, settings, principal, newUser);
|
||||
userFromDb = await UnitOfWork.UserRepository.GetUserByIdAsync(newUser.Id);
|
||||
Assert.NotNull(userFromDb);
|
||||
Assert.True(await userManager.IsInRoleAsync(newUser, PolicyConstants.ChangePasswordRole));
|
||||
Assert.Equal(AgeRating.Teen, userFromDb.AgeRestriction);
|
||||
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FindBestAvailableName_NoDuplicates()
|
||||
{
|
||||
await ResetDb();
|
||||
var (oidcService, _, _, userManager) = await Setup();
|
||||
|
||||
|
||||
const string preferredName = "PreferredName";
|
||||
const string name = "Name";
|
||||
const string givenName = "GivenName";
|
||||
const string surname = "Surname";
|
||||
const string email = "Email";
|
||||
|
||||
var claims = new List<Claim>()
|
||||
{
|
||||
new(JwtRegisteredClaimNames.PreferredUsername, preferredName),
|
||||
new(ClaimTypes.Name, name),
|
||||
new(ClaimTypes.GivenName, givenName),
|
||||
new(ClaimTypes.Surname, surname),
|
||||
new(ClaimTypes.Email, email),
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
var bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(preferredName, bestName);
|
||||
|
||||
// Create user with this name to make the method fallback to the next claim
|
||||
var user = new AppUserBuilder(bestName, bestName).Build();
|
||||
var res = await userManager.CreateAsync(user);
|
||||
// This has actual information as to why it would fail, so we check it to make sure if the test fail here we know why
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// Fallback to name
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(name, bestName);
|
||||
|
||||
user = new AppUserBuilder(bestName, bestName).Build();
|
||||
res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// Fallback to given name
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(givenName, bestName);
|
||||
|
||||
user = new AppUserBuilder(bestName, bestName).Build();
|
||||
res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// Fallback to surname
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.NotNull(bestName);
|
||||
Assert.Equal(surname, bestName);
|
||||
|
||||
user = new AppUserBuilder(bestName, bestName).Build();
|
||||
res = await userManager.CreateAsync(user);
|
||||
Assert.Empty(res.Errors);
|
||||
Assert.True(res.Succeeded);
|
||||
|
||||
// When none are found, returns null
|
||||
bestName = await oidcService.FindBestAvailableName(principal);
|
||||
Assert.Null(bestName);
|
||||
}
|
||||
|
||||
private async Task<(OidcService, AppUser, IAccountService, UserManager<AppUser>)> Setup()
|
||||
{
|
||||
var defaultAdmin = new AppUserBuilder("defaultAdmin", "defaultAdmin@localhost")
|
||||
.WithRole(PolicyConstants.AdminRole)
|
||||
.Build();
|
||||
var user = new AppUserBuilder("amelia", "amelia@localhost").Build();
|
||||
|
||||
var roleStore = new RoleStore<
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserRole<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
|
||||
var roleManager = new RoleManager<AppRole>(
|
||||
roleStore,
|
||||
[new RoleValidator<AppRole>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
Substitute.For<ILogger<RoleManager<AppRole>>>());
|
||||
|
||||
foreach (var role in PolicyConstants.ValidRoles)
|
||||
{
|
||||
if (!await roleManager.RoleExistsAsync(role))
|
||||
{
|
||||
await roleManager.CreateAsync(new AppRole
|
||||
{
|
||||
Name = role,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var userStore = new UserStore<
|
||||
AppUser,
|
||||
AppRole,
|
||||
DataContext,
|
||||
int,
|
||||
IdentityUserClaim<int>,
|
||||
AppUserRole,
|
||||
IdentityUserLogin<int>,
|
||||
IdentityUserToken<int>,
|
||||
IdentityRoleClaim<int>
|
||||
>(Context);
|
||||
var userManager = new UserManager<AppUser>(userStore,
|
||||
new OptionsWrapper<IdentityOptions>(new IdentityOptions()),
|
||||
new PasswordHasher<AppUser>(),
|
||||
[new UserValidator<AppUser>()],
|
||||
[new PasswordValidator<AppUser>()],
|
||||
new UpperInvariantLookupNormalizer(),
|
||||
new IdentityErrorDescriber(),
|
||||
null!,
|
||||
Substitute.For<ILogger<UserManager<AppUser>>>());
|
||||
|
||||
// Create users with the UserManager such that the SecurityStamp is set
|
||||
await userManager.CreateAsync(user);
|
||||
await userManager.CreateAsync(defaultAdmin);
|
||||
|
||||
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(), UnitOfWork, Mapper, Substitute.For<ILocalizationService>());
|
||||
var oidcService = new OidcService(Substitute.For<ILogger<OidcService>>(), userManager, UnitOfWork, accountService, Substitute.For<IEmailService>());
|
||||
return (oidcService, user, accountService, userManager);
|
||||
}
|
||||
|
||||
protected override async Task ResetDb()
|
||||
{
|
||||
Context.AppUser.RemoveRange(Context.AppUser);
|
||||
Context.Library.RemoveRange(Context.Library);
|
||||
await UnitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -20,6 +22,11 @@ public class SettingsServiceTests
|
||||
private readonly ISettingsService _settingsService;
|
||||
private readonly IUnitOfWork _mockUnitOfWork;
|
||||
|
||||
private const string DefaultAgeKey = "default_age";
|
||||
private const string DefaultFieldSource = "default_source";
|
||||
private readonly static AgeRating DefaultAgeRating = AgeRating.Everyone;
|
||||
private readonly static MetadataFieldType DefaultSourceField = MetadataFieldType.Genre;
|
||||
|
||||
public SettingsServiceTests()
|
||||
{
|
||||
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new FileSystem());
|
||||
@ -27,9 +34,195 @@ public class SettingsServiceTests
|
||||
_mockUnitOfWork = Substitute.For<IUnitOfWork>();
|
||||
_settingsService = new SettingsService(_mockUnitOfWork, ds,
|
||||
Substitute.For<ILibraryWatcher>(), Substitute.For<ITaskScheduler>(),
|
||||
Substitute.For<ILogger<SettingsService>>());
|
||||
Substitute.For<ILogger<SettingsService>>(), Substitute.For<IOidcService>());
|
||||
}
|
||||
|
||||
#region ImportMetadataSettings
|
||||
|
||||
[Fact]
|
||||
public async Task ImportFieldMappings_ReplaceMode()
|
||||
{
|
||||
var existingSettings = CreateDefaultMetadataSettingsDto();
|
||||
var newSettings = new MetadataSettingsDto
|
||||
{
|
||||
Whitelist = ["new_whitelist_item"],
|
||||
Blacklist = ["new_blacklist_item"],
|
||||
AgeRatingMappings = new Dictionary<string, AgeRating> { ["new_age"] = AgeRating.R18Plus },
|
||||
FieldMappings =
|
||||
[
|
||||
new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag }
|
||||
],
|
||||
};
|
||||
|
||||
var importSettings = new ImportSettingsDto
|
||||
{
|
||||
ImportMode = ImportMode.Replace,
|
||||
Whitelist = true,
|
||||
Blacklist = true,
|
||||
AgeRatings = true,
|
||||
FieldMappings = true,
|
||||
Resolution = ConflictResolution.Manual,
|
||||
AgeRatingConflictResolutions = [],
|
||||
};
|
||||
|
||||
var settingsRepo = Substitute.For<ISettingsRepository>();
|
||||
settingsRepo.GetMetadataSettingDto().Returns(existingSettings);
|
||||
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
|
||||
|
||||
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Empty(result.AgeRatingConflicts);
|
||||
|
||||
Assert.Equal(existingSettings.Whitelist, newSettings.Whitelist);
|
||||
Assert.Equal(existingSettings.Blacklist, newSettings.Blacklist);
|
||||
Assert.Equal(existingSettings.AgeRatingMappings, newSettings.AgeRatingMappings);
|
||||
Assert.Equal(existingSettings.FieldMappings, newSettings.FieldMappings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportFieldMappings_MergeMode_WithNoConflicts()
|
||||
{
|
||||
var existingSettingsDto = CreateDefaultMetadataSettingsDto();
|
||||
var existingSettings = CreateDefaultMetadataSettings();
|
||||
|
||||
var newSettings = new MetadataSettingsDto
|
||||
{
|
||||
Whitelist = ["new_whitelist_item"],
|
||||
Blacklist = ["new_blacklist_item"],
|
||||
AgeRatingMappings = new Dictionary<string, AgeRating> { ["new_age"] = AgeRating.R18Plus },
|
||||
FieldMappings =
|
||||
[
|
||||
new MetadataFieldMappingDto { Id = 10, SourceValue = "new_source", SourceType = MetadataFieldType.Genre, DestinationValue = "new_dest", DestinationType = MetadataFieldType.Tag },
|
||||
],
|
||||
};
|
||||
|
||||
var importSettings = new ImportSettingsDto
|
||||
{
|
||||
ImportMode = ImportMode.Merge,
|
||||
Whitelist = true,
|
||||
Blacklist = true,
|
||||
AgeRatings = true,
|
||||
FieldMappings = true,
|
||||
Resolution = ConflictResolution.Manual,
|
||||
AgeRatingConflictResolutions = [],
|
||||
};
|
||||
|
||||
var settingsRepo = Substitute.For<ISettingsRepository>();
|
||||
settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto);
|
||||
settingsRepo.GetMetadataSettings().Returns(existingSettings);
|
||||
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
|
||||
|
||||
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Empty(result.AgeRatingConflicts);
|
||||
|
||||
Assert.Contains("default_white", existingSettingsDto.Whitelist);
|
||||
Assert.Contains("new_whitelist_item", existingSettingsDto.Whitelist);
|
||||
Assert.Contains("default_black", existingSettingsDto.Blacklist);
|
||||
Assert.Contains("new_blacklist_item", existingSettingsDto.Blacklist);
|
||||
Assert.Equal(2, existingSettingsDto.AgeRatingMappings.Count);
|
||||
Assert.Equal(2, existingSettingsDto.FieldMappings.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportFieldMappings_MergeMode_UseConfiguredOverrides()
|
||||
{
|
||||
var existingSettingsDto = CreateDefaultMetadataSettingsDto();
|
||||
var existingSettings = CreateDefaultMetadataSettings();
|
||||
|
||||
var newSettings = new MetadataSettingsDto
|
||||
{
|
||||
Whitelist = [],
|
||||
Blacklist = [],
|
||||
AgeRatingMappings = new Dictionary<string, AgeRating> { [DefaultAgeKey] = AgeRating.R18Plus },
|
||||
FieldMappings =
|
||||
[
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
Id = 20,
|
||||
SourceValue = DefaultFieldSource,
|
||||
SourceType = DefaultSourceField,
|
||||
DestinationValue = "different_dest",
|
||||
DestinationType = MetadataFieldType.Genre,
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
var importSettings = new ImportSettingsDto
|
||||
{
|
||||
ImportMode = ImportMode.Merge,
|
||||
Whitelist = false,
|
||||
Blacklist = false,
|
||||
AgeRatings = true,
|
||||
FieldMappings = true,
|
||||
Resolution = ConflictResolution.Manual,
|
||||
AgeRatingConflictResolutions = new Dictionary<string, ConflictResolution> { [DefaultAgeKey] = ConflictResolution.Replace },
|
||||
};
|
||||
|
||||
var settingsRepo = Substitute.For<ISettingsRepository>();
|
||||
settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto);
|
||||
settingsRepo.GetMetadataSettings().Returns(existingSettings);
|
||||
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
|
||||
|
||||
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Empty(result.AgeRatingConflicts);
|
||||
|
||||
Assert.Equal(AgeRating.R18Plus, existingSettingsDto.AgeRatingMappings[DefaultAgeKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ImportFieldMappings_MergeMode_SkipIdenticalMappings()
|
||||
{
|
||||
var existingSettingsDto = CreateDefaultMetadataSettingsDto();
|
||||
var existingSettings = CreateDefaultMetadataSettings();
|
||||
|
||||
var newSettings = new MetadataSettingsDto
|
||||
{
|
||||
Whitelist = [],
|
||||
Blacklist = [],
|
||||
AgeRatingMappings = new Dictionary<string, AgeRating> { ["existing_age"] = AgeRating.Mature }, // Same value
|
||||
FieldMappings =
|
||||
[
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
Id = 20,
|
||||
SourceValue = "existing_source",
|
||||
SourceType = MetadataFieldType.Genre,
|
||||
DestinationValue = "existing_dest", // Same destination
|
||||
DestinationType = MetadataFieldType.Tag // Same destination type
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
var importSettings = new ImportSettingsDto
|
||||
{
|
||||
ImportMode = ImportMode.Merge,
|
||||
Whitelist = false,
|
||||
Blacklist = false,
|
||||
AgeRatings = true,
|
||||
FieldMappings = true,
|
||||
Resolution = ConflictResolution.Manual,
|
||||
AgeRatingConflictResolutions = [],
|
||||
};
|
||||
|
||||
var settingsRepo = Substitute.For<ISettingsRepository>();
|
||||
settingsRepo.GetMetadataSettingDto().Returns(existingSettingsDto);
|
||||
settingsRepo.GetMetadataSettings().Returns(existingSettings);
|
||||
_mockUnitOfWork.SettingsRepository.Returns(settingsRepo);
|
||||
|
||||
var result = await _settingsService.ImportFieldMappings(newSettings, importSettings);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Empty(result.AgeRatingConflicts);
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region UpdateMetadataSettings
|
||||
|
||||
[Fact]
|
||||
@ -289,4 +482,46 @@ public class SettingsServiceTests
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private MetadataSettingsDto CreateDefaultMetadataSettingsDto()
|
||||
{
|
||||
return new MetadataSettingsDto
|
||||
{
|
||||
Whitelist = ["default_white"],
|
||||
Blacklist = ["default_black"],
|
||||
AgeRatingMappings = new Dictionary<string, AgeRating> { ["default_age"] = AgeRating.Everyone },
|
||||
FieldMappings =
|
||||
[
|
||||
new MetadataFieldMappingDto
|
||||
{
|
||||
Id = 1,
|
||||
SourceValue = "default_source",
|
||||
SourceType = MetadataFieldType.Genre,
|
||||
DestinationValue = "default_dest",
|
||||
DestinationType = MetadataFieldType.Tag
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private MetadataSettings CreateDefaultMetadataSettings()
|
||||
{
|
||||
return new MetadataSettings
|
||||
{
|
||||
Whitelist = ["default_white"],
|
||||
Blacklist = ["default_black"],
|
||||
AgeRatingMappings = new Dictionary<string, AgeRating> { [DefaultAgeKey] = DefaultAgeRating },
|
||||
FieldMappings =
|
||||
[
|
||||
new MetadataFieldMapping
|
||||
{
|
||||
Id = 1,
|
||||
SourceValue = DefaultFieldSource,
|
||||
SourceType = DefaultSourceField,
|
||||
DestinationValue = "default_dest",
|
||||
DestinationType = MetadataFieldType.Tag
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ using API.Data.Repositories;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.Email;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Errors;
|
||||
@ -52,6 +53,7 @@ public class AccountController : BaseApiController
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly IEventHub _eventHub;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IOidcService _oidcService;
|
||||
|
||||
/// <inheritdoc />
|
||||
public AccountController(UserManager<AppUser> userManager,
|
||||
@ -60,7 +62,8 @@ public class AccountController : BaseApiController
|
||||
ILogger<AccountController> logger,
|
||||
IMapper mapper, IAccountService accountService,
|
||||
IEmailService emailService, IEventHub eventHub,
|
||||
ILocalizationService localizationService)
|
||||
ILocalizationService localizationService,
|
||||
IOidcService oidcService)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_signInManager = signInManager;
|
||||
@ -72,6 +75,50 @@ public class AccountController : BaseApiController
|
||||
_emailService = emailService;
|
||||
_eventHub = eventHub;
|
||||
_localizationService = localizationService;
|
||||
_oidcService = oidcService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if OIDC authentication cookies are present
|
||||
/// </summary>
|
||||
/// <remarks>Makes not guarantee about their validity</remarks>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oidc-authenticated")]
|
||||
public ActionResult<bool> OidcAuthenticated()
|
||||
{
|
||||
return HttpContext.Request.Cookies.ContainsKey(OidcService.CookieName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current user, as it would from login
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="UnauthorizedAccessException"></exception>
|
||||
/// <remarks>Does not return tokens for the user</remarks>
|
||||
/// <remarks>Updates the last active date for the user</remarks>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<UserDto>> GetCurrentUserAsync()
|
||||
{
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
|
||||
if (user == null) throw new UnauthorizedAccessException();
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
if (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
|
||||
|
||||
try
|
||||
{
|
||||
user.UpdateLastActive();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
|
||||
}
|
||||
|
||||
_unitOfWork.UserRepository.Update(user);
|
||||
await _unitOfWork.CommitAsync();
|
||||
|
||||
return Ok(await ConstructUserDto(user, roles, false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -151,10 +198,10 @@ public class AccountController : BaseApiController
|
||||
if (!result.Succeeded) return BadRequest(result.Errors);
|
||||
|
||||
// Assign default streams
|
||||
AddDefaultStreamsToUser(user);
|
||||
_accountService.AddDefaultStreamsToUser(user);
|
||||
|
||||
// Assign default reading profile
|
||||
await AddDefaultReadingProfileToUser(user);
|
||||
await _accountService.AddDefaultReadingProfileToUser(user);
|
||||
|
||||
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
if (string.IsNullOrEmpty(token)) return BadRequest(await _localizationService.Get("en", "confirm-token-gen"));
|
||||
@ -224,6 +271,11 @@ public class AccountController : BaseApiController
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
|
||||
|
||||
var oidcConfig = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
// Setting only takes effect if OIDC is functional, and if we're not logging in via ApiKey
|
||||
var disablePasswordAuthentication = oidcConfig is {Enabled: true, DisablePasswordAuthentication: true} && string.IsNullOrEmpty(loginDto.ApiKey);
|
||||
if (disablePasswordAuthentication && !roles.Contains(PolicyConstants.AdminRole)) return Unauthorized(await _localizationService.Translate(user.Id, "password-authentication-disabled"));
|
||||
|
||||
if (string.IsNullOrEmpty(loginDto.ApiKey))
|
||||
{
|
||||
var result = await _signInManager
|
||||
@ -249,7 +301,14 @@ public class AccountController : BaseApiController
|
||||
}
|
||||
|
||||
// Update LastActive on account
|
||||
user.UpdateLastActive();
|
||||
try
|
||||
{
|
||||
user.UpdateLastActive();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
|
||||
}
|
||||
|
||||
// NOTE: This can likely be removed
|
||||
user.UserPreferences ??= new AppUserPreferences
|
||||
@ -262,18 +321,28 @@ public class AccountController : BaseApiController
|
||||
|
||||
_logger.LogInformation("{UserName} logged in at {Time}", user.UserName, user.LastActive);
|
||||
|
||||
return Ok(await ConstructUserDto(user, roles));
|
||||
}
|
||||
|
||||
private async Task<UserDto> ConstructUserDto(AppUser user, IList<string> roles, bool includeTokens = true)
|
||||
{
|
||||
var dto = _mapper.Map<UserDto>(user);
|
||||
dto.Token = await _tokenService.CreateToken(user);
|
||||
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
|
||||
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion))
|
||||
.Value;
|
||||
|
||||
if (includeTokens)
|
||||
{
|
||||
dto.Token = await _tokenService.CreateToken(user);
|
||||
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
|
||||
}
|
||||
|
||||
dto.Roles = roles;
|
||||
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value;
|
||||
|
||||
var pref = await _unitOfWork.UserRepository.GetPreferencesAsync(user.UserName!);
|
||||
if (pref == null) return Ok(dto);
|
||||
if (pref == null) return dto;
|
||||
|
||||
pref.Theme ??= await _unitOfWork.SiteThemeRepository.GetDefaultTheme();
|
||||
dto.Preferences = _mapper.Map<UserPreferencesDto>(pref);
|
||||
|
||||
return Ok(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -286,13 +355,9 @@ public class AccountController : BaseApiController
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(User.GetUserId(), AppUserIncludes.UserPreferences);
|
||||
if (user == null) return Unauthorized();
|
||||
|
||||
var dto = _mapper.Map<UserDto>(user);
|
||||
dto.Token = await _tokenService.CreateToken(user);
|
||||
dto.RefreshToken = await _tokenService.CreateRefreshToken(user);
|
||||
dto.KavitaVersion = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion))
|
||||
.Value;
|
||||
dto.Preferences = _mapper.Map<UserPreferencesDto>(user.UserPreferences);
|
||||
return Ok(dto);
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
|
||||
return Ok(await ConstructUserDto(user, roles, !HttpContext.Request.Cookies.ContainsKey(OidcService.CookieName)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -505,6 +570,7 @@ public class AccountController : BaseApiController
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Users who's <see cref="AppUser.IdentityProvider"/> is not <see cref="IdentityProvider.Kavita"/> cannot be edited if <see cref="OidcConfigDto.SyncUserSettings"/> is true</remarks>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
|
||||
@ -517,6 +583,16 @@ public class AccountController : BaseApiController
|
||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
|
||||
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
if (await _accountService.ChangeIdentityProvider(User.GetUserId(), user, dto.IdentityProvider)) return Ok();
|
||||
}
|
||||
catch (KavitaException exception)
|
||||
{
|
||||
return BadRequest(exception.Message);
|
||||
}
|
||||
|
||||
// Check if username is changing
|
||||
if (!user.UserName!.Equals(dto.Username))
|
||||
{
|
||||
@ -670,10 +746,10 @@ public class AccountController : BaseApiController
|
||||
if (!result.Succeeded) return BadRequest(result.Errors);
|
||||
|
||||
// Assign default streams
|
||||
AddDefaultStreamsToUser(user);
|
||||
_accountService.AddDefaultStreamsToUser(user);
|
||||
|
||||
// Assign default reading profile
|
||||
await AddDefaultReadingProfileToUser(user);
|
||||
await _accountService.AddDefaultReadingProfileToUser(user);
|
||||
|
||||
// Assign Roles
|
||||
var roles = dto.Roles;
|
||||
@ -772,29 +848,6 @@ public class AccountController : BaseApiController
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-invite-user"));
|
||||
}
|
||||
|
||||
private void AddDefaultStreamsToUser(AppUser user)
|
||||
{
|
||||
foreach (var newStream in Seed.DefaultStreams.Select(stream => _mapper.Map<AppUserDashboardStream, AppUserDashboardStream>(stream)))
|
||||
{
|
||||
user.DashboardStreams.Add(newStream);
|
||||
}
|
||||
|
||||
foreach (var stream in Seed.DefaultSideNavStreams.Select(stream => _mapper.Map<AppUserSideNavStream, AppUserSideNavStream>(stream)))
|
||||
{
|
||||
user.SideNavStreams.Add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddDefaultReadingProfileToUser(AppUser user)
|
||||
{
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Default Profile")
|
||||
.WithKind(ReadingProfileKind.Default)
|
||||
.Build();
|
||||
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last step in authentication flow, confirms the email token for email
|
||||
/// </summary>
|
||||
|
37
API/Controllers/OidcController.cs
Normal file
37
API/Controllers/OidcController.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using API.Extensions;
|
||||
using API.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
[Route("[controller]")]
|
||||
public class OidcController: ControllerBase
|
||||
{
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("login")]
|
||||
public IActionResult Login(string returnUrl = "/")
|
||||
{
|
||||
var properties = new AuthenticationProperties { RedirectUri = returnUrl };
|
||||
return Challenge(properties, IdentityServiceExtensions.OpenIdConnect);
|
||||
}
|
||||
|
||||
[HttpGet("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
|
||||
if (!Request.Cookies.ContainsKey(OidcService.CookieName))
|
||||
{
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
return SignOut(
|
||||
new AuthenticationProperties { RedirectUri = "/login" },
|
||||
CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
IdentityServiceExtensions.OpenIdConnect);
|
||||
}
|
||||
|
||||
}
|
@ -4,6 +4,7 @@ using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Email;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Settings;
|
||||
@ -253,4 +254,55 @@ public class SettingsController : BaseApiController
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Import field mappings
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("import-field-mappings")]
|
||||
public async Task<ActionResult<FieldMappingsImportResultDto>> ImportFieldMappings([FromBody] ImportFieldMappingsDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await _settingsService.ImportFieldMappings(dto.Data, dto.Settings));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "There was an issue importing field mappings");
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve publicly required configuration regarding Oidc
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oidc")]
|
||||
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
|
||||
{
|
||||
var settings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
var publicConfig = _mapper.Map<OidcPublicConfigDto>(settings);
|
||||
publicConfig.Enabled = !string.IsNullOrEmpty(settings.Authority) &&
|
||||
!string.IsNullOrEmpty(settings.ClientId) &&
|
||||
!string.IsNullOrEmpty(settings.Secret);
|
||||
|
||||
return Ok(publicConfig);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate if the given authority is reachable from the server
|
||||
/// </summary>
|
||||
/// <param name="authority"></param>
|
||||
/// <returns></returns>
|
||||
[Authorize("RequireAdminRole")]
|
||||
[HttpPost("is-valid-authority")]
|
||||
public async Task<ActionResult<bool>> IsValidAuthority([FromBody] AuthorityValidationDto authority)
|
||||
{
|
||||
return Ok(await _settingsService.IsValidAuthority(authority.Authority));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Account;
|
||||
#nullable enable
|
||||
@ -25,4 +26,5 @@ public sealed record UpdateUserDto
|
||||
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
|
||||
/// <inheritdoc cref="API.Entities.AppUser.Email"/>
|
||||
public string? Email { get; set; } = default!;
|
||||
public IdentityProvider IdentityProvider { get; init; } = IdentityProvider.Kavita;
|
||||
}
|
||||
|
86
API/DTOs/ImportFieldMappings.cs
Normal file
86
API/DTOs/ImportFieldMappings.cs
Normal file
@ -0,0 +1,86 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// How Kavita should import the new settings
|
||||
/// </summary>
|
||||
public enum ImportMode
|
||||
{
|
||||
[Description("Replace")]
|
||||
Replace = 0,
|
||||
[Description("Merge")]
|
||||
Merge = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How Kavita should resolve conflicts
|
||||
/// </summary>
|
||||
public enum ConflictResolution
|
||||
{
|
||||
/// <summary>
|
||||
/// Require the user to override the default
|
||||
/// </summary>
|
||||
[Description("Manual")]
|
||||
Manual = 0,
|
||||
/// <summary>
|
||||
/// Keep current value
|
||||
/// </summary>
|
||||
[Description("Keep")]
|
||||
Keep = 1,
|
||||
/// <summary>
|
||||
/// Replace with imported value
|
||||
/// </summary>
|
||||
[Description("Replace")]
|
||||
Replace = 2,
|
||||
}
|
||||
|
||||
public sealed record ImportSettingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// How Kavita should import the new settings
|
||||
/// </summary>
|
||||
public ImportMode ImportMode { get; init; }
|
||||
/// <summary>
|
||||
/// Default conflict resolution, override with <see cref="AgeRatingConflictResolutions"/> and <see cref="FieldMappingsConflictResolutions"/>
|
||||
/// </summary>
|
||||
public ConflictResolution Resolution { get; init; }
|
||||
/// <summary>
|
||||
/// Import <see cref="MetadataSettingsDto.Whitelist"/>
|
||||
/// </summary>
|
||||
public bool Whitelist { get; init; }
|
||||
/// <summary>
|
||||
/// Import <see cref="MetadataSettingsDto.Blacklist"/>
|
||||
/// </summary>
|
||||
public bool Blacklist { get; init; }
|
||||
/// <summary>
|
||||
/// Import <see cref="MetadataSettingsDto.AgeRatingMappings"/>
|
||||
/// </summary>
|
||||
public bool AgeRatings { get; init; }
|
||||
/// <summary>
|
||||
/// Import <see cref="MetadataSettingsDto.FieldMappings"/>
|
||||
/// </summary>
|
||||
public bool FieldMappings { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Override the <see cref="Resolution"/> for specific age ratings
|
||||
/// </summary>
|
||||
/// <remarks>Key is the tag</remarks>
|
||||
public Dictionary<string, ConflictResolution> AgeRatingConflictResolutions { get; init; }
|
||||
}
|
||||
|
||||
public sealed record FieldMappingsImportResultDto
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
/// <summary>
|
||||
/// Only present if <see cref="Success"/> is true
|
||||
/// </summary>
|
||||
public MetadataSettingsDto ResultingMetadataSettings { get; init; }
|
||||
/// <summary>
|
||||
/// Keys of the conflicting age ratings mappings
|
||||
/// </summary>
|
||||
public List<string> AgeRatingConflicts { get; init; }
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.MetadataMatching;
|
||||
@ -7,13 +8,18 @@ using NotImplementedException = System.NotImplementedException;
|
||||
namespace API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
|
||||
public sealed record MetadataSettingsDto
|
||||
public sealed record MetadataSettingsDto: FieldMappingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// If writing any sort of metadata from upstream (AniList, Hardcover) source is allowed
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable processing of metadata outside K+; e.g. disk and API
|
||||
/// </summary>
|
||||
public bool EnableExtendedMetadataProcessing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow the Summary to be written
|
||||
/// </summary>
|
||||
@ -75,28 +81,11 @@ public sealed record MetadataSettingsDto
|
||||
/// </summary>
|
||||
public bool FirstLastPeopleNaming { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
|
||||
/// </summary>
|
||||
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of rules that allow mapping a genre/tag to another genre/tag
|
||||
/// </summary>
|
||||
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
|
||||
/// <summary>
|
||||
/// A list of overrides that will enable writing to locked fields
|
||||
/// </summary>
|
||||
public List<MetadataSettingField> Overrides { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Do not allow any Genre/Tag in this list to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Blacklist { get; set; }
|
||||
/// <summary>
|
||||
/// Only allow these Tags to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Whitelist { get; set; }
|
||||
/// <summary>
|
||||
/// Which Roles to allow metadata downloading for
|
||||
/// </summary>
|
||||
@ -123,3 +112,30 @@ public sealed record MetadataSettingsDto
|
||||
return PersonRoles.Contains(character);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decoupled from <see cref="MetadataSettingsDto"/> to allow reuse without requiring the full metadata settings in
|
||||
/// <see cref="ImportFieldMappingsDto"/>
|
||||
/// </summary>
|
||||
public record FieldMappingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Do not allow any Genre/Tag in this list to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Blacklist { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Only allow these Tags to be written to Kavita
|
||||
/// </summary>
|
||||
public List<string> Whitelist { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Any Genres or Tags that if present, will trigger an Age Rating Override. Highest rating will be prioritized for matching.
|
||||
/// </summary>
|
||||
public Dictionary<string, AgeRating> AgeRatingMappings { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A list of rules that allow mapping a genre/tag to another genre/tag
|
||||
/// </summary>
|
||||
public List<MetadataFieldMappingDto> FieldMappings { get; set; }
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
#nullable enable
|
||||
@ -24,4 +25,5 @@ public sealed record MemberDto
|
||||
public DateTime LastActiveUtc { get; init; }
|
||||
public IEnumerable<LibraryDto>? Libraries { get; init; }
|
||||
public IEnumerable<string>? Roles { get; init; }
|
||||
public IdentityProvider IdentityProvider { get; init; }
|
||||
}
|
||||
|
3
API/DTOs/Settings/AuthorityValidationDto.cs
Normal file
3
API/DTOs/Settings/AuthorityValidationDto.cs
Normal file
@ -0,0 +1,3 @@
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
public sealed record AuthorityValidationDto(string Authority);
|
15
API/DTOs/Settings/ImportFieldMappingsDto.cs
Normal file
15
API/DTOs/Settings/ImportFieldMappingsDto.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
public sealed record ImportFieldMappingsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Import settings
|
||||
/// </summary>
|
||||
public ImportSettingsDto Settings { get; init; }
|
||||
/// <summary>
|
||||
/// Data to import
|
||||
/// </summary>
|
||||
public FieldMappingsDto Data { get; init; }
|
||||
}
|
68
API/DTOs/Settings/OidcConfigDto.cs
Normal file
68
API/DTOs/Settings/OidcConfigDto.cs
Normal file
@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// All configuration regarding OIDC
|
||||
/// </summary>
|
||||
/// <remarks>This class is saved as a JsonObject in the DB, assign default values to prevent unexpected NPE</remarks>
|
||||
public sealed record OidcConfigDto: OidcPublicConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional OpenID Connect Authority URL. Not managed in DB. Managed in appsettings.json and synced to DB.
|
||||
/// </summary>
|
||||
public string Authority { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Optional OpenID Connect ClientId, defaults to kavita. Not managed in DB. Managed in appsettings.json and synced to DB.
|
||||
/// </summary>
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Optional OpenID Connect Secret. Not managed in DB. Managed in appsettings.json and synced to DB.
|
||||
/// </summary>
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// If true, auto creates a new account when someone logs in via OpenID Connect
|
||||
/// </summary>
|
||||
public bool ProvisionAccounts { get; set; } = false;
|
||||
/// <summary>
|
||||
/// Require emails to be verified by the OpenID Connect provider when creating accounts on login
|
||||
/// </summary>
|
||||
public bool RequireVerifiedEmail { get; set; } = true;
|
||||
/// <summary>
|
||||
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provided roles on log in.
|
||||
/// </summary>
|
||||
public bool SyncUserSettings { get; set; } = false;
|
||||
/// <summary>
|
||||
/// A prefix that all roles Kavita checks for during sync must have
|
||||
/// </summary>
|
||||
public string RolesPrefix { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// The JWT claim roles are mapped under, defaults to <see cref="ClaimTypes.Role"/>
|
||||
/// </summary>
|
||||
public string RolesClaim { get; set; } = ClaimTypes.Role;
|
||||
/// <summary>
|
||||
/// Custom scopes Kavita should request from your OIDC provider
|
||||
/// </summary>
|
||||
/// <remarks>Advanced setting</remarks>
|
||||
public List<string> CustomScopes { get; set; } = [];
|
||||
|
||||
// Default values used when SyncUserSettings is false
|
||||
#region Default user settings
|
||||
|
||||
public List<string> DefaultRoles { get; set; } = [];
|
||||
public List<int> DefaultLibraries { get; set; } = [];
|
||||
public AgeRating DefaultAgeRestriction { get; set; } = AgeRating.Unknown;
|
||||
public bool DefaultIncludeUnknowns { get; set; } = false;
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the <see cref="OidcPublicConfigDto.Authority"/> has been set
|
||||
/// </summary>
|
||||
public bool Enabled => !string.IsNullOrEmpty(Authority);
|
||||
}
|
24
API/DTOs/Settings/OidcPublicConfigDto.cs
Normal file
24
API/DTOs/Settings/OidcPublicConfigDto.cs
Normal file
@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
/**
|
||||
* The part of the OIDC configuration that is returned by the API without authentication
|
||||
*/
|
||||
public record OidcPublicConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically redirect to the Oidc login screen
|
||||
/// </summary>
|
||||
public bool AutoLogin { get; set; }
|
||||
/// <summary>
|
||||
/// Disables password authentication for non-admin users
|
||||
/// </summary>
|
||||
public bool DisablePasswordAuthentication { get; set; }
|
||||
/// <summary>
|
||||
/// Name of your provider, used to display on the login screen
|
||||
/// </summary>
|
||||
/// <remarks>Default to OpenID Connect</remarks>
|
||||
public string ProviderName { get; set; } = "OpenID Connect";
|
||||
public bool Enabled { get; set; } = false;
|
||||
}
|
@ -92,6 +92,11 @@ public sealed record ServerSettingDto
|
||||
/// SMTP Configuration
|
||||
/// </summary>
|
||||
public SmtpConfigDto SmtpConfig { get; set; }
|
||||
/// <summary>
|
||||
/// OIDC Configuration
|
||||
/// </summary>
|
||||
public OidcConfigDto OidcConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Date Kavita was first installed
|
||||
/// </summary>
|
||||
|
@ -22,6 +22,10 @@ public sealed record LibraryStatV3
|
||||
/// </summary>
|
||||
public bool CreateReadingListsFromMetadata { get; set; }
|
||||
/// <summary>
|
||||
/// If the library has metadata turned on
|
||||
/// </summary>
|
||||
public bool EnabledMetadata { get; set; }
|
||||
/// <summary>
|
||||
/// Type of the Library
|
||||
/// </summary>
|
||||
public LibraryType LibraryType { get; set; }
|
||||
|
@ -131,6 +131,10 @@ public sealed record ServerInfoV3Dto
|
||||
/// Is this server using Kavita+
|
||||
/// </summary>
|
||||
public bool ActiveKavitaPlusSubscription { get; set; }
|
||||
/// <summary>
|
||||
/// Is OIDC enabled
|
||||
/// </summary>
|
||||
public bool OidcEnabled { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Users
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Data.Misc;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Device;
|
||||
|
||||
namespace API.DTOs.Stats.V3;
|
||||
@ -76,6 +77,10 @@ public sealed record UserStatV3
|
||||
/// Roles for this user
|
||||
/// </summary>
|
||||
public ICollection<string> Roles { get; set; }
|
||||
/// <summary>
|
||||
/// Who manages the user (OIDC, Kavita)
|
||||
/// </summary>
|
||||
public IdentityProvider IdentityProvider { get; set; }
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
#nullable enable
|
||||
@ -9,10 +11,13 @@ public sealed record UserDto
|
||||
{
|
||||
public string Username { get; init; } = null!;
|
||||
public string Email { get; init; } = null!;
|
||||
public IList<string> Roles { get; set; } = [];
|
||||
public string Token { get; set; } = null!;
|
||||
public string? RefreshToken { get; set; }
|
||||
public string? ApiKey { get; init; }
|
||||
public UserPreferencesDto? Preferences { get; set; }
|
||||
public AgeRestrictionDto? AgeRestriction { get; init; }
|
||||
public string KavitaVersion { get; set; }
|
||||
/// <inheritdoc cref="AppUser.IdentityProvider"/>
|
||||
public IdentityProvider IdentityProvider { get; init; }
|
||||
}
|
||||
|
@ -300,6 +300,10 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
||||
v => JsonSerializer.Deserialize<IList<MetadataSettingField>>(v, JsonSerializerOptions.Default) ?? new List<MetadataSettingField>())
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue(new List<MetadataSettingField>());
|
||||
|
||||
builder.Entity<AppUser>()
|
||||
.Property(user => user.IdentityProvider)
|
||||
.HasDefaultValue(IdentityProvider.Kavita);
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
|
@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using API.Entities.History;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Data.ManualMigrations;
|
||||
|
||||
/// <summary>
|
||||
/// v0.8.8 - If Kavita+ users had Metadata Matching settings already, ensure the new non-Kavita+ system is enabled to match
|
||||
/// existing experience
|
||||
/// </summary>
|
||||
public static class ManualMigrateEnableMetadataMatchingDefault
|
||||
{
|
||||
public static async Task Migrate(DataContext context, IUnitOfWork unitOfWork, ILogger<Program> logger)
|
||||
{
|
||||
if (await context.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateEnableMetadataMatchingDefault"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
logger.LogCritical("Running ManualMigrateEnableMetadataMatchingDefault migration - Please be patient, this may take some time. This is not an error");
|
||||
|
||||
var settings = await unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
|
||||
|
||||
var shouldBeEnabled = settings != null && (settings.Enabled || settings.AgeRatingMappings.Count != 0 ||
|
||||
settings.Blacklist.Count != 0 || settings.Whitelist.Count != 0 ||
|
||||
settings.Whitelist.Count != 0 || settings.Blacklist.Count != 0 ||
|
||||
settings.FieldMappings.Count != 0);
|
||||
|
||||
if (shouldBeEnabled && !settings.EnableExtendedMetadataProcessing)
|
||||
{
|
||||
var mSettings = await unitOfWork.SettingsRepository.GetMetadataSettings();
|
||||
mSettings.EnableExtendedMetadataProcessing = shouldBeEnabled;
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
|
||||
await context.ManualMigrationHistory.AddAsync(new ManualMigrationHistory()
|
||||
{
|
||||
Name = "ManualMigrateEnableMetadataMatchingDefault",
|
||||
ProductVersion = BuildInfo.Version.ToString(),
|
||||
RanAt = DateTime.UtcNow
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
logger.LogCritical("Running ManualMigrateEnableMetadataMatchingDefault migration - Completed. This is not an error");
|
||||
}
|
||||
}
|
3727
API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs
generated
Normal file
3727
API/Data/Migrations/20250727185204_AddEnableExtendedMetadataProcessing.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEnableExtendedMetadataProcessing : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "EnableExtendedMetadataProcessing",
|
||||
table: "MetadataSettings",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EnableExtendedMetadataProcessing",
|
||||
table: "MetadataSettings");
|
||||
}
|
||||
}
|
||||
}
|
3736
API/Data/Migrations/20250802103258_OpenIDConnect.Designer.cs
generated
Normal file
3736
API/Data/Migrations/20250802103258_OpenIDConnect.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
API/Data/Migrations/20250802103258_OpenIDConnect.cs
Normal file
39
API/Data/Migrations/20250802103258_OpenIDConnect.cs
Normal file
@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class OpenIDConnect : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "IdentityProvider",
|
||||
table: "AspNetUsers",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OidcId",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdentityProvider",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OidcId",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
@ -17,7 +17,7 @@ namespace API.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppRole", b =>
|
||||
{
|
||||
@ -90,6 +90,11 @@ namespace API.Data.Migrations
|
||||
b.Property<bool>("HasRunScrobbleEventGeneration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("IdentityProvider")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<DateTime>("LastActive")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -116,6 +121,9 @@ namespace API.Data.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OidcId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@ -1862,6 +1870,9 @@ namespace API.Data.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true);
|
||||
|
||||
b.Property<bool>("EnableExtendedMetadataProcessing")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("EnableGenres")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@ -3637,7 +3648,8 @@ namespace API.Data.Migrations
|
||||
|
||||
b.Navigation("TableOfContents");
|
||||
|
||||
b.Navigation("UserPreferences");
|
||||
b.Navigation("UserPreferences")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("UserRoles");
|
||||
|
||||
|
@ -107,6 +107,13 @@ public interface IUserRepository
|
||||
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
||||
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
||||
/// <summary>
|
||||
/// Try getting a user by the id provided by OIDC
|
||||
/// </summary>
|
||||
/// <param name="oidcId"></param>
|
||||
/// <param name="includes"></param>
|
||||
/// <returns></returns>
|
||||
Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
@ -557,6 +564,16 @@ public class UserRepository : IUserRepository
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUser?> GetByOidcId(string? oidcId, AppUserIncludes includes = AppUserIncludes.None)
|
||||
{
|
||||
if (string.IsNullOrEmpty(oidcId)) return null;
|
||||
|
||||
return await _context.AppUser
|
||||
.Where(u => u.OidcId == oidcId)
|
||||
.Includes(includes)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
@ -789,6 +806,7 @@ public class UserRepository : IUserRepository
|
||||
LastActiveUtc = u.LastActiveUtc,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
IsPending = !u.EmailConfirmed,
|
||||
IdentityProvider = u.IdentityProvider,
|
||||
AgeRestriction = new AgeRestrictionDto()
|
||||
{
|
||||
AgeRating = u.AgeRestriction,
|
||||
@ -800,7 +818,7 @@ public class UserRepository : IUserRepository
|
||||
Type = l.Type,
|
||||
LastScanned = l.LastScanned,
|
||||
Folders = l.Folders.Select(x => x.Path).ToList()
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
})
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
|
@ -5,9 +5,11 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
@ -252,6 +254,7 @@ public static class Seed
|
||||
new() {
|
||||
Key = ServerSettingKey.CacheSize, Value = Configuration.DefaultCacheMemory + string.Empty
|
||||
}, // Not used from DB, but DB is sync with appSettings.json
|
||||
new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())},
|
||||
|
||||
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
|
||||
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
|
||||
@ -289,9 +292,29 @@ public static class Seed
|
||||
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value =
|
||||
Configuration.CacheSize + string.Empty;
|
||||
|
||||
await SetOidcSettingsFromDisk(context);
|
||||
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task SetOidcSettingsFromDisk(DataContext context)
|
||||
{
|
||||
var oidcSettingEntry = await context.ServerSetting
|
||||
.FirstOrDefaultAsync(setting => setting.Key == ServerSettingKey.OidcConfiguration);
|
||||
|
||||
var storedOidcSettings = JsonSerializer.Deserialize<OidcConfigDto>(oidcSettingEntry!.Value)!;
|
||||
|
||||
var diskOidcSettings = Configuration.OidcSettings;
|
||||
|
||||
storedOidcSettings.Authority = diskOidcSettings.Authority;
|
||||
storedOidcSettings.ClientId = diskOidcSettings.ClientId;
|
||||
storedOidcSettings.Secret = diskOidcSettings.Secret;
|
||||
storedOidcSettings.CustomScopes = diskOidcSettings.CustomScopes;
|
||||
|
||||
oidcSettingEntry.Value = JsonSerializer.Serialize(storedOidcSettings);
|
||||
}
|
||||
|
||||
public static async Task SeedMetadataSettings(DataContext context)
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
|
@ -1,6 +1,8 @@
|
||||
using System;
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Interfaces;
|
||||
using API.Entities.Scrobble;
|
||||
@ -89,6 +91,15 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||
/// <remarks>Kavita+ only</remarks>
|
||||
public DateTime ScrobbleEventGenerationRan { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The sub returned the by OIDC provider
|
||||
/// </summary>
|
||||
public string? OidcId { get; set; }
|
||||
/// <summary>
|
||||
/// The IdentityProvider for the user, default to <see cref="Enums.IdentityProvider.Kavita"/>
|
||||
/// </summary>
|
||||
public IdentityProvider IdentityProvider { get; set; } = IdentityProvider.Kavita;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A list of Series the user doesn't want scrobbling for
|
||||
|
14
API/Entities/Enums/IdentityProvider.cs
Normal file
14
API/Entities/Enums/IdentityProvider.cs
Normal file
@ -0,0 +1,14 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.Entities.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Who provides the identity of the user
|
||||
/// </summary>
|
||||
public enum IdentityProvider
|
||||
{
|
||||
[Description("Kavita")]
|
||||
Kavita = 0,
|
||||
[Description("OpenID Connect")]
|
||||
OpenIdConnect = 1,
|
||||
}
|
@ -197,4 +197,10 @@ public enum ServerSettingKey
|
||||
/// </summary>
|
||||
[Description("FirstInstallVersion")]
|
||||
FirstInstallVersion = 39,
|
||||
/// <summary>
|
||||
/// A Json object of type <see cref="API.DTOs.Settings.OidcConfigDto"/>
|
||||
/// </summary>
|
||||
[Description("OidcConfiguration")]
|
||||
OidcConfiguration = 40,
|
||||
|
||||
}
|
||||
|
@ -14,6 +14,11 @@ public class MetadataSettings
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Enable processing of metadata outside K+; e.g. disk and API
|
||||
/// </summary>
|
||||
public bool EnableExtendedMetadataProcessing { get; set; }
|
||||
|
||||
#region Series Metadata
|
||||
|
||||
/// <summary>
|
||||
|
@ -4,12 +4,14 @@ using API.Data;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Store;
|
||||
using API.Services.Tasks;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.SignalR;
|
||||
using API.SignalR.Presence;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
@ -83,6 +85,8 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<ISmartCollectionSyncService, SmartCollectionSyncService>();
|
||||
services.AddScoped<IWantToReadSyncService, WantToReadSyncService>();
|
||||
|
||||
services.AddScoped<IOidcService, OidcService>();
|
||||
|
||||
services.AddSqLite();
|
||||
services.AddSignalR(opt => opt.EnableDetailedErrors = true);
|
||||
|
||||
@ -106,6 +110,7 @@ public static class ApplicationServiceExtensions
|
||||
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB
|
||||
options.CompactionPercentage = 0.1; // LRU compaction (10%)
|
||||
});
|
||||
services.AddSingleton<ITicketStore, CustomTicketStore>();
|
||||
|
||||
services.AddSwaggerGen(g =>
|
||||
{
|
||||
|
@ -1,4 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using API.Constants;
|
||||
using Kavita.Common;
|
||||
using JwtRegisteredClaimNames = Microsoft.IdentityModel.JsonWebTokens.JwtRegisteredClaimNames;
|
||||
|
||||
@ -8,6 +11,8 @@ namespace API.Extensions;
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
private const string NotAuthenticatedMessage = "User is not authenticated";
|
||||
private const string EmailVerifiedClaimType = "email_verified";
|
||||
|
||||
/// <summary>
|
||||
/// Get's the authenticated user's username
|
||||
/// </summary>
|
||||
@ -26,4 +31,26 @@ public static class ClaimsPrincipalExtensions
|
||||
var userClaim = user.FindFirst(ClaimTypes.NameIdentifier) ?? throw new KavitaException(NotAuthenticatedMessage);
|
||||
return int.Parse(userClaim.Value);
|
||||
}
|
||||
|
||||
public static bool HasVerifiedEmail(this ClaimsPrincipal user)
|
||||
{
|
||||
var emailVerified = user.FindFirst(EmailVerifiedClaimType);
|
||||
if (emailVerified == null) return false;
|
||||
|
||||
if (!bool.TryParse(emailVerified.Value, out bool emailVerifiedValue) || !emailVerifiedValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static IList<string> GetClaimsWithPrefix(this ClaimsPrincipal claimsPrincipal, string claimType, string prefix)
|
||||
{
|
||||
return claimsPrincipal
|
||||
.FindAll(claimType)
|
||||
.Where(c => c.Value.StartsWith(prefix))
|
||||
.Select(c => c.Value.TrimPrefix(prefix))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
43
API/Extensions/EnumExtensions.cs
Normal file
43
API/Extensions/EnumExtensions.cs
Normal file
@ -0,0 +1,43 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Reflection;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
||||
public static class EnumExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension on Enum.TryParse which also tried matching on the description attribute
|
||||
/// </summary>
|
||||
/// <returns>if a match was found</returns>
|
||||
/// <remarks>First tries Enum.TryParse then fall back to the more expensive operation</remarks>
|
||||
public static bool TryParse<TEnum>(string? value, out TEnum result) where TEnum : struct, Enum
|
||||
{
|
||||
result = default;
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Enum.TryParse(value, out result))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (var field in typeof(TEnum).GetFields(BindingFlags.Public | BindingFlags.Static))
|
||||
{
|
||||
var description = field.GetCustomAttribute<DescriptionAttribute>()?.Description;
|
||||
|
||||
if (!string.IsNullOrEmpty(description) &&
|
||||
string.Equals(description, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result = (TEnum)field.GetValue(null)!;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
@ -6,6 +6,7 @@ using API.Data.Misc;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace API.Extensions;
|
||||
#nullable enable
|
||||
|
@ -1,21 +1,43 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Entities;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
|
||||
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.OpenIdConnect.TokenValidatedContext;
|
||||
|
||||
namespace API.Extensions;
|
||||
#nullable enable
|
||||
|
||||
public static class IdentityServiceExtensions
|
||||
{
|
||||
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config)
|
||||
private const string DynamicHybrid = nameof(DynamicHybrid);
|
||||
public const string OpenIdConnect = nameof(OpenIdConnect);
|
||||
private const string LocalIdentity = nameof(LocalIdentity);
|
||||
|
||||
private const string OidcCallback = "/signin-oidc";
|
||||
private const string OidcLogoutCallback = "/signout-callback-oidc";
|
||||
|
||||
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config, IWebHostEnvironment environment)
|
||||
{
|
||||
services.Configure<IdentityOptions>(options =>
|
||||
{
|
||||
@ -47,42 +69,264 @@ public static class IdentityServiceExtensions
|
||||
.AddRoleValidator<RoleValidator<AppRole>>()
|
||||
.AddEntityFrameworkStores<DataContext>();
|
||||
|
||||
var oidcSettings = Configuration.OidcSettings;
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
var auth = services.AddAuthentication(DynamicHybrid)
|
||||
.AddPolicyScheme(DynamicHybrid, JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters()
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidIssuer = "Kavita"
|
||||
};
|
||||
var enabled = oidcSettings.Enabled;
|
||||
|
||||
options.Events = new JwtBearerEvents()
|
||||
options.ForwardDefaultSelector = ctx =>
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
if (!enabled) return LocalIdentity;
|
||||
|
||||
if (ctx.Request.Path.StartsWithSegments(OidcCallback) ||
|
||||
ctx.Request.Path.StartsWithSegments(OidcLogoutCallback))
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
// Only use query string based token on SignalR hubs
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
return OpenIdConnect;
|
||||
}
|
||||
|
||||
if (ctx.Request.Headers.Authorization.Count != 0)
|
||||
{
|
||||
return LocalIdentity;
|
||||
}
|
||||
|
||||
if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName))
|
||||
{
|
||||
return OpenIdConnect;
|
||||
}
|
||||
|
||||
return LocalIdentity;
|
||||
};
|
||||
|
||||
});
|
||||
services.AddAuthorization(opt =>
|
||||
|
||||
|
||||
if (oidcSettings.Enabled)
|
||||
{
|
||||
opt.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole));
|
||||
opt.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole));
|
||||
opt.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
|
||||
services.SetupOpenIdConnectAuthentication(auth, oidcSettings, environment);
|
||||
}
|
||||
|
||||
auth.AddJwtBearer(LocalIdentity, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidIssuer = "Kavita",
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = SetTokenFromQuery,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
services.AddAuthorizationBuilder()
|
||||
.AddPolicy("RequireAdminRole", policy => policy.RequireRole(PolicyConstants.AdminRole))
|
||||
.AddPolicy("RequireDownloadRole", policy => policy.RequireRole(PolicyConstants.DownloadRole, PolicyConstants.AdminRole))
|
||||
.AddPolicy("RequireChangePasswordRole", policy => policy.RequireRole(PolicyConstants.ChangePasswordRole, PolicyConstants.AdminRole));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void SetupOpenIdConnectAuthentication(this IServiceCollection services, AuthenticationBuilder auth,
|
||||
Configuration.OpenIdConnectSettings settings, IWebHostEnvironment environment)
|
||||
{
|
||||
var isDevelopment = environment.IsEnvironment(Environments.Development);
|
||||
var baseUrl = Configuration.BaseUrl;
|
||||
|
||||
var apiPrefix = baseUrl + "api";
|
||||
var hubsPrefix = baseUrl + "hubs";
|
||||
|
||||
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme).Configure<ITicketStore>((options, store) =>
|
||||
{
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(7);
|
||||
options.SlidingExpiration = true;
|
||||
|
||||
options.Cookie.HttpOnly = true;
|
||||
options.Cookie.IsEssential = true;
|
||||
options.Cookie.MaxAge = TimeSpan.FromDays(7);
|
||||
options.SessionStore = store;
|
||||
|
||||
if (isDevelopment)
|
||||
{
|
||||
options.Cookie.Domain = null;
|
||||
}
|
||||
|
||||
options.Events = new CookieAuthenticationEvents
|
||||
{
|
||||
OnValidatePrincipal = async ctx =>
|
||||
{
|
||||
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
|
||||
var user = await oidcService.RefreshCookieToken(ctx);
|
||||
|
||||
if (user != null)
|
||||
{
|
||||
var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user!, false);
|
||||
ctx.ReplacePrincipal(new ClaimsPrincipal(new ClaimsIdentity(claims, ctx.Scheme.Name)));
|
||||
}
|
||||
},
|
||||
OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
auth.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
auth.AddOpenIdConnect(OpenIdConnect, options =>
|
||||
{
|
||||
options.Authority = settings.Authority;
|
||||
options.ClientId = settings.ClientId;
|
||||
options.ClientSecret = settings.Secret;
|
||||
options.RequireHttpsMetadata = options.Authority.StartsWith("https://");
|
||||
|
||||
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
options.ResponseType = OpenIdConnectResponseType.Code;
|
||||
options.CallbackPath = OidcCallback;
|
||||
options.SignedOutCallbackPath = OidcLogoutCallback;
|
||||
|
||||
options.SaveTokens = true;
|
||||
options.GetClaimsFromUserInfoEndpoint = true;
|
||||
options.Scope.Clear();
|
||||
options.Scope.Add("openid");
|
||||
options.Scope.Add("profile");
|
||||
options.Scope.Add("offline_access");
|
||||
options.Scope.Add("roles");
|
||||
options.Scope.Add("email");
|
||||
|
||||
foreach (var customScope in settings.CustomScopes)
|
||||
{
|
||||
options.Scope.Add(customScope);
|
||||
}
|
||||
|
||||
options.Events = new OpenIdConnectEvents
|
||||
{
|
||||
OnTokenValidated = OidcClaimsPrincipalConverter,
|
||||
OnAuthenticationFailed = ctx =>
|
||||
{
|
||||
ctx.Response.Redirect(baseUrl + "login?skipAutoLogin=true&error=" + Uri.EscapeDataString(ctx.Exception.Message));
|
||||
ctx.HandleResponse();
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToIdentityProviderForSignOut = ctx =>
|
||||
{
|
||||
if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.PostLogoutRedirectUri))
|
||||
{
|
||||
ctx.ProtocolMessage.PostLogoutRedirectUri = ctx.ProtocolMessage.PostLogoutRedirectUri.Replace("http://", "https://");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToIdentityProvider = ctx =>
|
||||
{
|
||||
// Intercept redirects on API requests and instead return 401
|
||||
// These redirects are auto login when .NET finds a cookie that it can't match inside the cookie store. I.e. after a restart
|
||||
if (ctx.Request.Path.StartsWithSegments(apiPrefix) || ctx.Request.Path.StartsWithSegments(hubsPrefix))
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
ctx.HandleResponse();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!isDevelopment && !string.IsNullOrEmpty(ctx.ProtocolMessage.RedirectUri))
|
||||
{
|
||||
ctx.ProtocolMessage.RedirectUri = ctx.ProtocolMessage.RedirectUri.Replace("http://", "https://");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called after the redirect from the OIDC provider, tries matching the user and update the principal
|
||||
/// to have the correct claims and properties. This is required to later auto refresh; and ensure .NET knows which
|
||||
/// Kavita roles the user has
|
||||
/// </summary>
|
||||
/// <param name="ctx"></param>
|
||||
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
|
||||
{
|
||||
if (ctx.Principal == null) return;
|
||||
|
||||
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
|
||||
var user = await oidcService.LoginOrCreate(ctx.Request, ctx.Principal);
|
||||
if (user == null)
|
||||
{
|
||||
throw new KavitaException("errors.oidc.no-account");
|
||||
}
|
||||
|
||||
var claims = await OidcService.ConstructNewClaimsList(ctx.HttpContext.RequestServices, ctx.Principal, user);
|
||||
var tokens = CopyOidcTokens(ctx);
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
ctx.Properties ??= new AuthenticationProperties();
|
||||
ctx.Properties.StoreTokens(tokens);
|
||||
|
||||
ctx.HttpContext.User = principal;
|
||||
ctx.Principal = principal;
|
||||
|
||||
ctx.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copy tokens returned by the OIDC provider that we require later
|
||||
/// </summary>
|
||||
/// <param name="ctx"></param>
|
||||
/// <returns></returns>
|
||||
private static List<AuthenticationToken> CopyOidcTokens(TokenValidatedContext ctx)
|
||||
{
|
||||
if (ctx.TokenEndpointResponse == null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var tokens = new List<AuthenticationToken>();
|
||||
|
||||
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.RefreshToken))
|
||||
{
|
||||
tokens.Add(new AuthenticationToken { Name = OidcService.RefreshToken, Value = ctx.TokenEndpointResponse.RefreshToken });
|
||||
}
|
||||
else
|
||||
{
|
||||
var logger = ctx.HttpContext.RequestServices.GetRequiredService<ILogger<OidcService>>();
|
||||
logger.LogWarning("OIDC login without refresh token, automatic sync will not work for this user");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.IdToken))
|
||||
{
|
||||
tokens.Add(new AuthenticationToken { Name = OidcService.IdToken, Value = ctx.TokenEndpointResponse.IdToken });
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ctx.TokenEndpointResponse.ExpiresIn))
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(ctx.TokenEndpointResponse.ExpiresIn));
|
||||
tokens.Add(new AuthenticationToken { Name = OidcService.ExpiresAt, Value = expiresAt.ToString("o") });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static Task SetTokenFromQuery(MessageReceivedContext context)
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
|
||||
// Only use query string based token on SignalR hubs
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
|
||||
{
|
||||
context.Token = accessToken;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
@ -52,4 +52,33 @@ public static class StringExtensions
|
||||
{
|
||||
return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static string TrimPrefix(this string? value, string prefix)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
|
||||
if (!value.StartsWith(prefix)) return value;
|
||||
|
||||
return value.Substring(prefix.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Censor the input string by removing all but the first and last char.
|
||||
/// </summary>
|
||||
/// <param name="input"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>If the input is an email (contains @), the domain will remain untouched</remarks>
|
||||
public static string Censor(this string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input)) return input ?? string.Empty;
|
||||
|
||||
var atIdx = input.IndexOf('@');
|
||||
if (atIdx == -1)
|
||||
{
|
||||
return $"{input[0]}{new string('*', input.Length - 1)}";
|
||||
}
|
||||
|
||||
return input[0] + new string('*', atIdx - 1) + input[atIdx..];
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -386,7 +386,6 @@ public class AutoMapperProfiles : Profile
|
||||
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
|
||||
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
||||
|
||||
|
||||
|
||||
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -129,6 +130,9 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
|
||||
case ServerSettingKey.FirstInstallVersion:
|
||||
destination.FirstInstallVersion = row.Value;
|
||||
break;
|
||||
case ServerSettingKey.OidcConfiguration:
|
||||
destination.OidcConfig = JsonSerializer.Deserialize<OidcConfigDto>(row.Value)!;
|
||||
break;
|
||||
case ServerSettingKey.LicenseKey:
|
||||
case ServerSettingKey.EnableAuthentication:
|
||||
case ServerSettingKey.EmailServiceUrl:
|
||||
|
@ -2,6 +2,7 @@
|
||||
"confirm-email": "You must confirm your email first",
|
||||
"locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.",
|
||||
"disabled-account": "Your account is disabled. Contact the server admin.",
|
||||
"password-authentication-disabled": "Password authentication has been disabled, login via OpenID Connect",
|
||||
"register-user": "Something went wrong when registering user",
|
||||
"validate-email": "There was an issue validating your email: {0}",
|
||||
"confirm-token-gen": "There was an issue generating a confirmation token",
|
||||
@ -17,6 +18,8 @@
|
||||
"generate-token": "There was an issue generating a confirmation email token. See logs",
|
||||
"age-restriction-update": "There was an error updating the age restriction",
|
||||
"no-user": "User does not exist",
|
||||
"oidc-managed": "Users managed by OIDC cannot be edited.",
|
||||
"cannot-change-identity-provider-original-user": "Identity Provider of the original admin account cannot be changed",
|
||||
"username-taken": "Username already taken",
|
||||
"email-taken": "Email already in use",
|
||||
"user-already-confirmed": "User is already confirmed",
|
||||
@ -42,6 +45,7 @@
|
||||
"email-not-enabled": "Email is not enabled on this server. You cannot perform this action.",
|
||||
"account-email-invalid": "The email on file for the admin account is not a valid email. Cannot send test email.",
|
||||
"email-settings-invalid": "Email settings missing information. Ensure all email settings are saved.",
|
||||
"oidc-invalid-authority": "OIDC authority is invalid",
|
||||
|
||||
"chapter-doesnt-exist": "Chapter does not exist",
|
||||
"file-missing": "File was not found in book",
|
||||
|
@ -1,19 +1,22 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Errors;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using API.SignalR;
|
||||
using AutoMapper;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
@ -24,25 +27,56 @@ public interface IAccountService
|
||||
{
|
||||
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
|
||||
Task<IEnumerable<ApiException>> ValidatePassword(AppUser user, string password);
|
||||
Task<IEnumerable<ApiException>> ValidateUsername(string username);
|
||||
Task<IEnumerable<ApiException>> ValidateUsername(string? username);
|
||||
Task<IEnumerable<ApiException>> ValidateEmail(string email);
|
||||
Task<bool> HasBookmarkPermission(AppUser? user);
|
||||
Task<bool> HasDownloadPermission(AppUser? user);
|
||||
Task<bool> CanChangeAgeRestriction(AppUser? user);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="actingUserId">The user who is changing the identity</param>
|
||||
/// <param name="user">the user being changed</param>
|
||||
/// <param name="identityProvider"> the provider being changed to</param>
|
||||
/// <returns>If true, user should not be updated by kavita (anymore)</returns>
|
||||
/// <exception cref="KavitaException">Throws if invalid actions are being performed</exception>
|
||||
Task<bool> ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider);
|
||||
/// <summary>
|
||||
/// Removes access to all libraries, then grant access to all given libraries or all libraries if the user is admin.
|
||||
/// Creates side nav streams as well
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="librariesIds"></param>
|
||||
/// <param name="hasAdminRole"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Ensure that the users SideNavStreams are loaded</remarks>
|
||||
/// <remarks>Does NOT commit</remarks>
|
||||
Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole);
|
||||
Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles);
|
||||
void AddDefaultStreamsToUser(AppUser user);
|
||||
Task AddDefaultReadingProfileToUser(AppUser user);
|
||||
}
|
||||
|
||||
public class AccountService : IAccountService
|
||||
public partial class AccountService : IAccountService
|
||||
{
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly UserManager<AppUser> _userManager;
|
||||
private readonly ILogger<AccountService> _logger;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
|
||||
public static readonly Regex AllowedUsernameRegex = AllowedUsernameRegexAttr();
|
||||
|
||||
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork)
|
||||
|
||||
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork,
|
||||
IMapper mapper, ILocalizationService localizationService)
|
||||
{
|
||||
_localizationService = localizationService;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
|
||||
@ -77,8 +111,13 @@ public class AccountService : IAccountService
|
||||
|
||||
return Array.Empty<ApiException>();
|
||||
}
|
||||
public async Task<IEnumerable<ApiException>> ValidateUsername(string username)
|
||||
public async Task<IEnumerable<ApiException>> ValidateUsername(string? username)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || !AllowedUsernameRegex.IsMatch(username))
|
||||
{
|
||||
return [new ApiException(400, "Invalid username")];
|
||||
}
|
||||
|
||||
// Reverted because of https://go.microsoft.com/fwlink/?linkid=2129535
|
||||
if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName != null
|
||||
&& x.NormalizedUserName == username.ToUpper()))
|
||||
@ -143,4 +182,113 @@ public class AccountService : IAccountService
|
||||
|
||||
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
public async Task<bool> ChangeIdentityProvider(int actingUserId, AppUser user, IdentityProvider identityProvider)
|
||||
{
|
||||
var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
if (user.Id == defaultAdminUser.Id)
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Translate(actingUserId, "cannot-change-identity-provider-original-user"));
|
||||
}
|
||||
|
||||
// Allow changes if users aren't being synced
|
||||
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
if (!oidcSettings.SyncUserSettings)
|
||||
{
|
||||
user.IdentityProvider = identityProvider;
|
||||
await _unitOfWork.CommitAsync();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't allow changes to the user if they're managed by oidc, and their identity provider isn't being changed to something else
|
||||
if (user.IdentityProvider == IdentityProvider.OpenIdConnect && identityProvider == IdentityProvider.OpenIdConnect)
|
||||
{
|
||||
throw new KavitaException(await _localizationService.Translate(actingUserId, "oidc-managed"));
|
||||
}
|
||||
|
||||
user.IdentityProvider = identityProvider;
|
||||
await _unitOfWork.CommitAsync();
|
||||
return user.IdentityProvider == IdentityProvider.OpenIdConnect;
|
||||
}
|
||||
|
||||
public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole)
|
||||
{
|
||||
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList();
|
||||
var currentLibrary = allLibraries.Where(l => l.AppUsers.Contains(user)).ToList();
|
||||
|
||||
List<Library> libraries;
|
||||
if (hasAdminRole)
|
||||
{
|
||||
_logger.LogDebug("{UserId} is admin. Granting access to all libraries", user.Id);
|
||||
libraries = allLibraries;
|
||||
}
|
||||
else
|
||||
{
|
||||
libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList();
|
||||
}
|
||||
|
||||
var toRemove = currentLibrary.Except(libraries);
|
||||
var toAdd = libraries.Except(currentLibrary);
|
||||
|
||||
foreach (var lib in toRemove)
|
||||
{
|
||||
lib.AppUsers ??= [];
|
||||
lib.AppUsers.Remove(user);
|
||||
user.RemoveSideNavFromLibrary(lib);
|
||||
}
|
||||
|
||||
foreach (var lib in toAdd)
|
||||
{
|
||||
lib.AppUsers ??= [];
|
||||
lib.AppUsers.Add(user);
|
||||
user.CreateSideNavFromLibrary(lib);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<IdentityError>> UpdateRolesForUser(AppUser user, IList<string> roles)
|
||||
{
|
||||
var existingRoles = await _userManager.GetRolesAsync(user);
|
||||
var hasAdminRole = roles.Contains(PolicyConstants.AdminRole);
|
||||
if (!hasAdminRole)
|
||||
{
|
||||
roles.Add(PolicyConstants.PlebRole);
|
||||
}
|
||||
|
||||
if (existingRoles.Except(roles).Any() || roles.Except(existingRoles).Any())
|
||||
{
|
||||
var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles);
|
||||
if (!roleResult.Succeeded) return roleResult.Errors;
|
||||
|
||||
roleResult = await _userManager.AddToRolesAsync(user, roles);
|
||||
if (!roleResult.Succeeded) return roleResult.Errors;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public void AddDefaultStreamsToUser(AppUser user)
|
||||
{
|
||||
foreach (var newStream in Seed.DefaultStreams.Select(_mapper.Map<AppUserDashboardStream, AppUserDashboardStream>))
|
||||
{
|
||||
user.DashboardStreams.Add(newStream);
|
||||
}
|
||||
|
||||
foreach (var stream in Seed.DefaultSideNavStreams.Select(_mapper.Map<AppUserSideNavStream, AppUserSideNavStream>))
|
||||
{
|
||||
user.SideNavStreams.Add(stream);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task AddDefaultReadingProfileToUser(AppUser user)
|
||||
{
|
||||
var profile = new AppUserReadingProfileBuilder(user.Id)
|
||||
.WithName("Default Profile")
|
||||
.WithKind(ReadingProfileKind.Default)
|
||||
.Build();
|
||||
_unitOfWork.AppUserReadingProfileRepository.Add(profile);
|
||||
await _unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9\-._@+/]*$")]
|
||||
private static partial Regex AllowedUsernameRegexAttr();
|
||||
}
|
||||
|
662
API/Services/OidcService.cs
Normal file
662
API/Services/OidcService.cs
Normal file
@ -0,0 +1,662 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Email;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using Hangfire;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IOidcService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the user authenticated with OpenID Connect
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="principal"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException">if any requirements aren't met</exception>
|
||||
Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal);
|
||||
/// <summary>
|
||||
/// Refresh the token inside the cookie when it's close to expiring. And sync the user
|
||||
/// </summary>
|
||||
/// <param name="ctx"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>If the token is refreshed successfully, updates the last active time of the suer</remarks>
|
||||
Task<AppUser?> RefreshCookieToken(CookieValidatePrincipalContext ctx);
|
||||
/// <summary>
|
||||
/// Remove <see cref="AppUser.OidcId"/> from all users
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task ClearOidcIds();
|
||||
}
|
||||
|
||||
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
|
||||
IUnitOfWork unitOfWork, IAccountService accountService, IEmailService emailService): IOidcService
|
||||
{
|
||||
public const string LibraryAccessPrefix = "library-";
|
||||
public const string AgeRestrictionPrefix = "age-restriction-";
|
||||
public const string IncludeUnknowns = "include-unknowns";
|
||||
public const string RefreshToken = "refresh_token";
|
||||
public const string IdToken = "id_token";
|
||||
public const string ExpiresAt = "expires_at";
|
||||
/// The name of the Auth Cookie set by .NET
|
||||
public const string CookieName = ".AspNetCore.Cookies";
|
||||
|
||||
private OpenIdConnectConfiguration? _discoveryDocument;
|
||||
private static readonly ConcurrentDictionary<string, bool> RefreshInProgress = new();
|
||||
private static readonly ConcurrentDictionary<string, DateTimeOffset> LastFailedRefresh = new();
|
||||
|
||||
public async Task<AppUser?> LoginOrCreate(HttpRequest request, ClaimsPrincipal principal)
|
||||
{
|
||||
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
|
||||
var oidcId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(oidcId))
|
||||
{
|
||||
throw new KavitaException("errors.oidc.missing-external-id");
|
||||
}
|
||||
|
||||
var user = await unitOfWork.UserRepository.GetByOidcId(oidcId, AppUserIncludes.UserPreferences);
|
||||
if (user != null) return user;
|
||||
|
||||
var email = principal.FindFirstValue(ClaimTypes.Email);
|
||||
if (string.IsNullOrEmpty(email))
|
||||
{
|
||||
throw new KavitaException("errors.oidc.missing-email");
|
||||
}
|
||||
|
||||
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
|
||||
{
|
||||
throw new KavitaException("errors.oidc.email-not-verified");
|
||||
}
|
||||
|
||||
|
||||
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams);
|
||||
if (user != null)
|
||||
{
|
||||
// Don't allow taking over accounts
|
||||
// This could happen if the user changes their email in OIDC, and then someone else uses the old one
|
||||
if (!string.IsNullOrEmpty(user.OidcId))
|
||||
{
|
||||
throw new KavitaException("errors.oidc.email-in-use");
|
||||
}
|
||||
|
||||
logger.LogDebug("User {UserName} has matched on email to {OidcId}", user.Id, oidcId);
|
||||
user.OidcId = oidcId;
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
return await CreateNewAccount(request, principal, settings, oidcId);
|
||||
}
|
||||
|
||||
public async Task<AppUser?> RefreshCookieToken(CookieValidatePrincipalContext ctx)
|
||||
{
|
||||
if (ctx.Principal == null) return null;
|
||||
|
||||
var user = await unitOfWork.UserRepository.GetUserByIdAsync(ctx.Principal.GetUserId()) ?? throw new UnauthorizedAccessException();
|
||||
var key = ctx.Principal.GetUsername();
|
||||
|
||||
var refreshToken = ctx.Properties.GetTokenValue(RefreshToken);
|
||||
if (string.IsNullOrEmpty(refreshToken)) return user;
|
||||
|
||||
var expiresAt = ctx.Properties.GetTokenValue(ExpiresAt);
|
||||
if (string.IsNullOrEmpty(expiresAt)) return user;
|
||||
|
||||
// Do not spam refresh if it failed
|
||||
if (LastFailedRefresh.TryGetValue(key, out var time) && time.AddMinutes(30) < DateTimeOffset.UtcNow) return user;
|
||||
|
||||
var tokenExpiry = DateTimeOffset.ParseExact(expiresAt, "o", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind);
|
||||
if (tokenExpiry >= DateTimeOffset.UtcNow.AddSeconds(30)) return user;
|
||||
|
||||
// Ensure we're not refreshing twice
|
||||
if (!RefreshInProgress.TryAdd(key, true)) return user;
|
||||
|
||||
try
|
||||
{
|
||||
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
|
||||
var tokenResponse = await RefreshTokenAsync(settings, refreshToken);
|
||||
if (!string.IsNullOrEmpty(tokenResponse.Error))
|
||||
{
|
||||
logger.LogTrace("Failed to refresh token : {Error} - {Description}", tokenResponse.Error, tokenResponse.ErrorDescription);
|
||||
LastFailedRefresh.TryAdd(key, DateTimeOffset.UtcNow);
|
||||
return user;
|
||||
}
|
||||
|
||||
var newExpiresAt = DateTimeOffset.UtcNow.AddSeconds(double.Parse(tokenResponse.ExpiresIn));
|
||||
ctx.Properties.UpdateTokenValue(ExpiresAt, newExpiresAt.ToString("o"));
|
||||
ctx.Properties.UpdateTokenValue(RefreshToken, tokenResponse.RefreshToken);
|
||||
ctx.Properties.UpdateTokenValue(IdToken, tokenResponse.IdToken);
|
||||
ctx.ShouldRenew = true;
|
||||
|
||||
try
|
||||
{
|
||||
user.UpdateLastActive();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to update last active for {UserName}", user.UserName);
|
||||
}
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(tokenResponse.IdToken))
|
||||
{
|
||||
logger.LogTrace("The OIDC provider did not return an id token in the refresh response, continuous sync is not supported");
|
||||
return user;
|
||||
}
|
||||
|
||||
await SyncUserSettings(ctx, settings, tokenResponse.IdToken, user);
|
||||
logger.LogTrace("Automatically refreshed token for user {UserId}", ctx.Principal?.GetUserId());
|
||||
}
|
||||
finally
|
||||
{
|
||||
RefreshInProgress.TryRemove(key, out _);
|
||||
LastFailedRefresh.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task ClearOidcIds()
|
||||
{
|
||||
var users = await unitOfWork.UserRepository.GetAllUsersAsync();
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.OidcId = null;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to construct a new account from the OIDC Principal, may fail if required conditions aren't met
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="principal"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="oidcId"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException"></exception>
|
||||
private async Task<AppUser?> CreateNewAccount(HttpRequest request, ClaimsPrincipal principal, OidcConfigDto settings, string oidcId)
|
||||
{
|
||||
var accessRoles = principal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix)
|
||||
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
|
||||
if (settings.SyncUserSettings && accessRoles.Count == 0)
|
||||
{
|
||||
throw new KavitaException("errors.oidc.role-not-assigned");
|
||||
}
|
||||
|
||||
AppUser? user;
|
||||
try
|
||||
{
|
||||
user = await NewUserFromOpenIdConnect(request, settings, principal, oidcId);
|
||||
}
|
||||
catch (KavitaException e)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "An error occured creating a new user");
|
||||
throw new KavitaException("errors.oidc.creating-user");
|
||||
}
|
||||
|
||||
if (user == null) return null;
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
if (roles.Count == 0 || (!roles.Contains(PolicyConstants.LoginRole) && !roles.Contains(PolicyConstants.AdminRole)))
|
||||
{
|
||||
throw new KavitaException("errors.oidc.disabled-account");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find the best available name from claims
|
||||
/// </summary>
|
||||
/// <param name="claimsPrincipal"></param>
|
||||
/// <param name="orEqualTo">Also return if the claim is equal to this value</param>
|
||||
/// <returns></returns>
|
||||
public async Task<string?> FindBestAvailableName(ClaimsPrincipal claimsPrincipal, string? orEqualTo = null)
|
||||
{
|
||||
var nameCandidates = new[]
|
||||
{
|
||||
claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername),
|
||||
claimsPrincipal.FindFirstValue(ClaimTypes.Name),
|
||||
claimsPrincipal.FindFirstValue(ClaimTypes.GivenName),
|
||||
claimsPrincipal.FindFirstValue(ClaimTypes.Surname)
|
||||
};
|
||||
|
||||
foreach (var name in nameCandidates.Where(n => !string.IsNullOrEmpty(n)))
|
||||
{
|
||||
if (name == orEqualTo || await IsNameAvailable(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> IsNameAvailable(string? name)
|
||||
{
|
||||
return !(await accountService.ValidateUsername(name)).Any();
|
||||
}
|
||||
|
||||
private async Task<AppUser?> NewUserFromOpenIdConnect(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId)
|
||||
{
|
||||
if (!settings.ProvisionAccounts) return null;
|
||||
|
||||
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
|
||||
if (string.IsNullOrWhiteSpace(emailClaim?.Value)) return null;
|
||||
|
||||
var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value;
|
||||
logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name.Censor(), externalId);
|
||||
|
||||
var user = new AppUserBuilder(name, emailClaim.Value,
|
||||
await unitOfWork.SiteThemeRepository.GetDefaultTheme()).Build();
|
||||
|
||||
var res = await userManager.CreateAsync(user);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
logger.LogError("Failed to create new user from OIDC: {Errors}",
|
||||
res.Errors.Select(x => x.Description).ToList());
|
||||
throw new KavitaException("errors.oidc.creating-user");
|
||||
}
|
||||
|
||||
if (claimsPrincipal.HasVerifiedEmail())
|
||||
{
|
||||
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
await userManager.ConfirmEmailAsync(user, token);
|
||||
}
|
||||
|
||||
user.OidcId = externalId;
|
||||
user.IdentityProvider = IdentityProvider.OpenIdConnect;
|
||||
|
||||
accountService.AddDefaultStreamsToUser(user);
|
||||
await accountService.AddDefaultReadingProfileToUser(user);
|
||||
|
||||
await SyncUserSettings(request, settings, claimsPrincipal, user);
|
||||
await SetDefaults(settings, user);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assign configured defaults (libraries, age ratings, roles) to the newly created user
|
||||
/// </summary>
|
||||
private async Task SetDefaults(OidcConfigDto settings, AppUser user)
|
||||
{
|
||||
if (settings.SyncUserSettings) return;
|
||||
|
||||
logger.LogDebug("Assigning defaults to newly created user; Roles: {Roles}, Libraries: {Libraries}, AgeRating: {AgeRating}, IncludeUnknowns: {IncludeUnknowns}",
|
||||
settings.DefaultRoles, settings.DefaultLibraries, settings.DefaultAgeRestriction, settings.DefaultIncludeUnknowns);
|
||||
|
||||
// Assign roles
|
||||
var errors = await accountService.UpdateRolesForUser(user, settings.DefaultRoles);
|
||||
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
|
||||
|
||||
// Assign libraries
|
||||
await accountService.UpdateLibrariesForUser(user, settings.DefaultLibraries, settings.DefaultRoles.Contains(PolicyConstants.AdminRole));
|
||||
|
||||
// Assign age rating
|
||||
user.AgeRestriction = settings.DefaultAgeRestriction;
|
||||
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private async Task SyncUserSettings(CookieValidatePrincipalContext ctx, OidcConfigDto settings, string idToken, AppUser user)
|
||||
{
|
||||
if (!settings.SyncUserSettings) return;
|
||||
|
||||
try
|
||||
{
|
||||
var newPrincipal = await ParseIdToken(settings, idToken);
|
||||
await SyncUserSettings(ctx.HttpContext.Request, settings, newPrincipal, user);
|
||||
}
|
||||
catch (KavitaException ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to sync user after token refresh");
|
||||
throw new UnauthorizedAccessException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates roles, library access and age rating restriction. Will not modify the default admin
|
||||
/// </summary>
|
||||
/// <param name="request"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="claimsPrincipal"></param>
|
||||
/// <param name="user"></param>
|
||||
public async Task SyncUserSettings(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
if (!settings.SyncUserSettings) return;
|
||||
|
||||
// Never sync the default user
|
||||
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
if (defaultAdminUser.Id == user.Id) return;
|
||||
|
||||
logger.LogDebug("Syncing user {UserId} from OIDC", user.Id);
|
||||
try
|
||||
{
|
||||
|
||||
await SyncEmail(request, settings, claimsPrincipal, user);
|
||||
await SyncUsername(claimsPrincipal, user);
|
||||
await SyncRoles(settings, claimsPrincipal, user);
|
||||
await SyncLibraries(settings, claimsPrincipal, user);
|
||||
await SyncAgeRestriction(settings, claimsPrincipal, user);
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
{
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to sync user {UserId} from OIDC", user.Id);
|
||||
await unitOfWork.RollbackAsync();
|
||||
throw new KavitaException("errors.oidc.syncing-user", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncEmail(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
|
||||
if (string.IsNullOrEmpty(email) || user.Email == email) return;
|
||||
|
||||
if (settings.RequireVerifiedEmail && !claimsPrincipal.HasVerifiedEmail())
|
||||
{
|
||||
throw new KavitaException("errors.oidc.email-not-verified");
|
||||
}
|
||||
|
||||
// Ensure no other user uses this email
|
||||
var other = await userManager.FindByEmailAsync(email);
|
||||
if (other != null)
|
||||
{
|
||||
throw new KavitaException("errors.oidc.email-in-use");
|
||||
}
|
||||
|
||||
// The email is verified, we can go ahead and change & confirm it
|
||||
if (claimsPrincipal.HasVerifiedEmail())
|
||||
{
|
||||
var res = await userManager.SetEmailAsync(user, email);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
logger.LogError("Failed to update email for user {UserId} from OIDC {Errors}", user.Id, res.Errors.Select(x => x.Description).ToList());
|
||||
throw new KavitaException("errors.oidc.failed-to-update-email");
|
||||
}
|
||||
|
||||
user.EmailConfirmed = true;
|
||||
await userManager.UpdateAsync(user);
|
||||
return;
|
||||
}
|
||||
|
||||
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
var isValidEmailAddress = !string.IsNullOrEmpty(user.Email) && emailService.IsValidEmail(user.Email);
|
||||
var isEmailSetup = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).IsEmailSetup();
|
||||
var shouldEmailUser = isEmailSetup || !isValidEmailAddress;
|
||||
|
||||
user.EmailConfirmed = !shouldEmailUser;
|
||||
user.ConfirmationToken = token;
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
var emailLink = await emailService.GenerateEmailLink(request, user.ConfirmationToken, "confirm-email-update", email);
|
||||
logger.LogCritical("[Update Email]: Automatic email update after OIDC sync, email Link for {UserId}: {Link}", user.Id, emailLink);
|
||||
|
||||
if (!shouldEmailUser)
|
||||
{
|
||||
logger.LogInformation("Cannot email admin, email not setup or admin email invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEmailAddress)
|
||||
{
|
||||
logger.LogCritical("[Update Email]: User is trying to update their email, but their existing email ({Email}) isn't valid. No email will be send", user.Email.Censor());
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invitingUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
BackgroundJob.Enqueue(() => emailService.SendEmailChangeEmail(new ConfirmationEmailDto()
|
||||
{
|
||||
EmailAddress = string.IsNullOrEmpty(user.Email) ? email : user.Email,
|
||||
InstallId = BuildInfo.Version.ToString(),
|
||||
InvitingUser = invitingUser.UserName,
|
||||
ServerConfirmationLink = emailLink,
|
||||
}));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception */
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private async Task SyncUsername(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var bestName = await FindBestAvailableName(claimsPrincipal, user.UserName);
|
||||
if (bestName == null || bestName == user.UserName) return;
|
||||
|
||||
var res = await userManager.SetUserNameAsync(user, bestName);
|
||||
if (!res.Succeeded)
|
||||
{
|
||||
logger.LogError("Failed to update username for user {UserId} to {NewUserName} from OIDC {Errors}", user.Id,
|
||||
bestName.Censor(), res.Errors.Select(x => x.Description).ToList());
|
||||
throw new KavitaException("errors.oidc.failed-to-update-username");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncRoles(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var roles = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, settings.RolesPrefix)
|
||||
.Where(s => PolicyConstants.ValidRoles.Contains(s)).ToList();
|
||||
logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles);
|
||||
|
||||
var errors = (await accountService.UpdateRolesForUser(user, roles)).ToList();
|
||||
if (errors.Any())
|
||||
{
|
||||
logger.LogError("Failed to sync roles {Errors}", errors.Select(x => x.Description).ToList());
|
||||
throw new KavitaException("errors.oidc.syncing-user");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncLibraries(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var libraryAccessPrefix = settings.RolesPrefix + LibraryAccessPrefix;
|
||||
var libraryAccess = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, libraryAccessPrefix);
|
||||
|
||||
logger.LogDebug("Syncing libraries for user {UserId}, found library roles {Roles}", user.Id, libraryAccess);
|
||||
|
||||
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||
// Distinct to ensure each library (id) is only present once
|
||||
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).Distinct().ToList();
|
||||
|
||||
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
|
||||
}
|
||||
|
||||
private async Task SyncAgeRestriction(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
|
||||
{
|
||||
logger.LogDebug("User {UserId} is admin, granting access to all age ratings", user.Id);
|
||||
user.AgeRestriction = AgeRating.NotApplicable;
|
||||
user.AgeRestrictionIncludeUnknowns = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var ageRatingPrefix = settings.RolesPrefix + AgeRestrictionPrefix;
|
||||
var ageRatings = claimsPrincipal.GetClaimsWithPrefix(settings.RolesClaim, ageRatingPrefix);
|
||||
logger.LogDebug("Syncing age restriction for user {UserId}, found restrictions {Restrictions}", user.Id, ageRatings);
|
||||
|
||||
if (ageRatings.Count == 0 || (ageRatings.Count == 1 && ageRatings.Contains(IncludeUnknowns)))
|
||||
{
|
||||
logger.LogDebug("No age restriction found in roles, setting to NotApplicable and Include Unknowns: {IncludeUnknowns}", settings.DefaultIncludeUnknowns);
|
||||
|
||||
user.AgeRestriction = AgeRating.NotApplicable;
|
||||
user.AgeRestrictionIncludeUnknowns = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var highestAgeRestriction = AgeRating.NotApplicable;
|
||||
|
||||
foreach (var ar in ageRatings)
|
||||
{
|
||||
if (!EnumExtensions.TryParse(ar, out AgeRating ageRating))
|
||||
{
|
||||
logger.LogDebug("Age Restriction role configured that failed to map to a known age rating: {RoleName}", AgeRestrictionPrefix+ar);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ageRating > highestAgeRestriction)
|
||||
{
|
||||
highestAgeRestriction = ageRating;
|
||||
}
|
||||
}
|
||||
|
||||
user.AgeRestriction = highestAgeRestriction;
|
||||
user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns);
|
||||
|
||||
logger.LogDebug("Synced age restriction for user {UserId}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}",
|
||||
user.Id, user.AgeRestriction, user.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the discovery document if not already loaded, then refreshed the tokens for the user
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="refreshToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
private async Task<OpenIdConnectMessage> RefreshTokenAsync(OidcConfigDto dto, string refreshToken)
|
||||
{
|
||||
|
||||
_discoveryDocument ??= await LoadOidcConfiguration(dto.Authority);
|
||||
|
||||
var msg = new
|
||||
{
|
||||
grant_type = RefreshToken,
|
||||
refresh_token = refreshToken,
|
||||
client_id = dto.ClientId,
|
||||
client_secret = dto.Secret,
|
||||
};
|
||||
|
||||
var json = await _discoveryDocument.TokenEndpoint
|
||||
.AllowAnyHttpStatus()
|
||||
.PostUrlEncodedAsync(msg)
|
||||
.ReceiveString();
|
||||
|
||||
return new OpenIdConnectMessage(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the discovery document if not already loaded, then parses the given id token securely
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="idToken"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="InvalidOperationException"></exception>
|
||||
private async Task<ClaimsPrincipal> ParseIdToken(OidcConfigDto dto, string idToken)
|
||||
{
|
||||
_discoveryDocument ??= await LoadOidcConfiguration(dto.Authority);
|
||||
var tokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidIssuer = _discoveryDocument.Issuer,
|
||||
ValidAudience = dto.ClientId,
|
||||
IssuerSigningKeys = _discoveryDocument.SigningKeys,
|
||||
ValidateIssuerSigningKey = true,
|
||||
};
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var principal = handler.ValidateToken(idToken, tokenValidationParameters, out _);
|
||||
|
||||
return principal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads OpenIdConnectConfiguration, includes <see cref="OpenIdConnectConfiguration.SigningKeys"/>
|
||||
/// </summary>
|
||||
/// <param name="authority"></param>
|
||||
/// <returns></returns>
|
||||
private static async Task<OpenIdConnectConfiguration> LoadOidcConfiguration(string authority)
|
||||
{
|
||||
var hasTrailingSlash = authority.EndsWith('/');
|
||||
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
|
||||
|
||||
var manager = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
url,
|
||||
new OpenIdConnectConfigurationRetriever(),
|
||||
new HttpDocumentRetriever { RequireHttps = url.StartsWith("https") }
|
||||
);
|
||||
|
||||
return await manager.GetConfigurationAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a list of claims in the same way the NativeJWT token would map them.
|
||||
/// Optionally include original claims if the claims are needed later in the pipeline
|
||||
/// </summary>
|
||||
/// <param name="services"></param>
|
||||
/// <param name="principal"></param>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="includeOriginalClaims"></param>
|
||||
/// <returns></returns>
|
||||
public static async Task<List<Claim>> ConstructNewClaimsList(IServiceProvider services, ClaimsPrincipal? principal, AppUser user, bool includeOriginalClaims = true)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
|
||||
new(ClaimTypes.Name, user.UserName ?? string.Empty),
|
||||
};
|
||||
|
||||
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
|
||||
if (includeOriginalClaims)
|
||||
{
|
||||
claims.AddRange(principal?.Claims ?? []);
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
}
|
@ -681,13 +681,34 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
return [.. staff];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method, calls <see cref="ProcessGenreAndTagLists"/>
|
||||
/// </summary>
|
||||
/// <param name="externalMetadata"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="processedTags"></param>
|
||||
/// <param name="processedGenres"></param>
|
||||
private static void GenerateGenreAndTagLists(ExternalSeriesDetailDto externalMetadata, MetadataSettingsDto settings,
|
||||
ref List<string> processedTags, ref List<string> processedGenres)
|
||||
{
|
||||
externalMetadata.Tags ??= [];
|
||||
externalMetadata.Genres ??= [];
|
||||
GenerateGenreAndTagLists(externalMetadata.Genres, externalMetadata.Tags.Select(t => t.Name).ToList(),
|
||||
settings, ref processedTags, ref processedGenres);
|
||||
}
|
||||
|
||||
var mappings = ApplyFieldMappings(externalMetadata.Tags.Select(t => t.Name), MetadataFieldType.Tag, settings.FieldMappings);
|
||||
/// <summary>
|
||||
/// Run all genres and tags through the Metadata settings
|
||||
/// </summary>
|
||||
/// <param name="genres">Genres to process</param>
|
||||
/// <param name="tags">Tags to process</param>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="processedTags"></param>
|
||||
/// <param name="processedGenres"></param>
|
||||
private static void GenerateGenreAndTagLists(IList<string> genres, IList<string> tags, MetadataSettingsDto settings,
|
||||
ref List<string> processedTags, ref List<string> processedGenres)
|
||||
{
|
||||
var mappings = ApplyFieldMappings(tags, MetadataFieldType.Tag, settings.FieldMappings);
|
||||
if (mappings.TryGetValue(MetadataFieldType.Tag, out var tagsToTags))
|
||||
{
|
||||
processedTags.AddRange(tagsToTags);
|
||||
@ -697,7 +718,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
processedGenres.AddRange(tagsToGenres);
|
||||
}
|
||||
|
||||
mappings = ApplyFieldMappings(externalMetadata.Genres, MetadataFieldType.Genre, settings.FieldMappings);
|
||||
mappings = ApplyFieldMappings(genres, MetadataFieldType.Genre, settings.FieldMappings);
|
||||
if (mappings.TryGetValue(MetadataFieldType.Tag, out var genresToTags))
|
||||
{
|
||||
processedTags.AddRange(genresToTags);
|
||||
@ -711,6 +732,30 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
processedGenres = ApplyBlackWhiteList(settings, MetadataFieldType.Genre, processedGenres);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the given tags and genres only if <see cref="MetadataSettingsDto.EnableExtendedMetadataProcessing"/>
|
||||
/// is true, else return without change
|
||||
/// </summary>
|
||||
/// <param name="genres"></param>
|
||||
/// <param name="tags"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="processedTags"></param>
|
||||
/// <param name="processedGenres"></param>
|
||||
public static void GenerateExternalGenreAndTagsList(IList<string> genres, IList<string> tags,
|
||||
MetadataSettingsDto settings, out List<string> processedTags, out List<string> processedGenres)
|
||||
{
|
||||
if (!settings.EnableExtendedMetadataProcessing)
|
||||
{
|
||||
processedTags = [..tags];
|
||||
processedGenres = [..genres];
|
||||
return;
|
||||
}
|
||||
|
||||
processedTags = [];
|
||||
processedGenres = [];
|
||||
GenerateGenreAndTagLists(genres, tags, settings, ref processedTags, ref processedGenres);
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateRelationships(Series series, MetadataSettingsDto settings, IList<SeriesRelationship>? externalMetadataRelations, AppUser defaultAdmin)
|
||||
{
|
||||
if (!settings.EnableRelationships) return false;
|
||||
@ -1003,16 +1048,19 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
private static List<string> ApplyBlackWhiteList(MetadataSettingsDto settings, MetadataFieldType fieldType, List<string> processedStrings)
|
||||
{
|
||||
var whiteList = settings.Whitelist.Select(t => t.ToNormalized()).ToList();
|
||||
var blackList = settings.Blacklist.Select(t => t.ToNormalized()).ToList();
|
||||
|
||||
return fieldType switch
|
||||
{
|
||||
MetadataFieldType.Genre => processedStrings.Distinct()
|
||||
.Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g))
|
||||
.Where(g => blackList.Count == 0 || !blackList.Contains(g.ToNormalized()))
|
||||
.ToList(),
|
||||
MetadataFieldType.Tag => processedStrings.Distinct()
|
||||
.Where(g => settings.Blacklist.Count == 0 || !settings.Blacklist.Contains(g))
|
||||
.Where(g => settings.Whitelist.Count == 0 || settings.Whitelist.Contains(g))
|
||||
.Where(g => blackList.Count == 0 || !blackList.Contains(g.ToNormalized()))
|
||||
.Where(g => whiteList.Count == 0 || whiteList.Contains(g.ToNormalized()))
|
||||
.ToList(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null)
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(fieldType), fieldType, null),
|
||||
};
|
||||
}
|
||||
|
||||
@ -1718,24 +1766,22 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
foreach (var value in values)
|
||||
{
|
||||
var mapping = mappings.FirstOrDefault(m =>
|
||||
var matchingMappings = mappings.Where(m =>
|
||||
m.SourceType == sourceType &&
|
||||
m.SourceValue.Equals(value, StringComparison.OrdinalIgnoreCase));
|
||||
m.SourceValue.ToNormalized().Equals(value.ToNormalized()));
|
||||
|
||||
if (mapping != null && !string.IsNullOrWhiteSpace(mapping.DestinationValue))
|
||||
var keepOriginal = true;
|
||||
|
||||
foreach (var mapping in matchingMappings.Where(mapping => !string.IsNullOrWhiteSpace(mapping.DestinationValue)))
|
||||
{
|
||||
var targetType = mapping.DestinationType;
|
||||
result[mapping.DestinationType].Add(mapping.DestinationValue);
|
||||
|
||||
if (!mapping.ExcludeFromSource)
|
||||
{
|
||||
result[sourceType].Add(mapping.SourceValue);
|
||||
}
|
||||
|
||||
result[targetType].Add(mapping.DestinationValue);
|
||||
// Only keep the original tags if none of the matches want to remove it
|
||||
keepOriginal = keepOriginal && !mapping.ExcludeFromSource;
|
||||
}
|
||||
else
|
||||
|
||||
if (keepOriginal)
|
||||
{
|
||||
// If no mapping, keep the original value
|
||||
result[sourceType].Add(value);
|
||||
}
|
||||
}
|
||||
@ -1760,9 +1806,10 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
// Find highest age rating from mappings
|
||||
mappings ??= new Dictionary<string, AgeRating>();
|
||||
mappings = mappings.ToDictionary(k => k.Key.ToNormalized(), k => k.Value);
|
||||
|
||||
return values
|
||||
.Select(v => mappings.TryGetValue(v, out var mapping) ? mapping : AgeRating.Unknown)
|
||||
.Select(v => mappings.TryGetValue(v.ToNormalized(), out var mapping) ? mapping : AgeRating.Unknown)
|
||||
.DefaultIfEmpty(AgeRating.Unknown)
|
||||
.Max();
|
||||
}
|
||||
|
@ -209,12 +209,17 @@ public class SeriesService : ISeriesService
|
||||
{
|
||||
var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
|
||||
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
|
||||
if (updatedRating > series.Metadata.AgeRating)
|
||||
|
||||
if (metadataSettings.EnableExtendedMetadataProcessing)
|
||||
{
|
||||
series.Metadata.AgeRating = updatedRating;
|
||||
series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating);
|
||||
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
|
||||
if (updatedRating > series.Metadata.AgeRating)
|
||||
{
|
||||
series.Metadata.AgeRating = updatedRating;
|
||||
series.Metadata.KPlusOverrides.Remove(MetadataSettingField.AgeRating);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,28 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.MetadataMatching;
|
||||
using API.Extensions;
|
||||
using API.Logging;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using Flurl.Http;
|
||||
using Hangfire;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface ISettingsService
|
||||
{
|
||||
Task<MetadataSettingsDto> UpdateMetadataSettings(MetadataSettingsDto dto);
|
||||
/// <summary>
|
||||
/// Update <see cref="MetadataSettings.Whitelist"/>, <see cref="MetadataSettings.Blacklist"/>, <see cref="MetadataSettings.AgeRatingMappings"/>, <see cref="MetadataSettings.FieldMappings"/>
|
||||
/// with data from the given dto.
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <returns></returns>
|
||||
Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings);
|
||||
Task<ServerSettingDto> UpdateSettings(ServerSettingDto updateSettingsDto);
|
||||
/// <summary>
|
||||
/// Check if the server can reach the authority at the given uri
|
||||
/// </summary>
|
||||
/// <param name="authority"></param>
|
||||
/// <returns></returns>
|
||||
Task<bool> IsValidAuthority(string authority);
|
||||
}
|
||||
|
||||
|
||||
@ -33,16 +53,18 @@ public class SettingsService : ISettingsService
|
||||
private readonly ILibraryWatcher _libraryWatcher;
|
||||
private readonly ITaskScheduler _taskScheduler;
|
||||
private readonly ILogger<SettingsService> _logger;
|
||||
private readonly IOidcService _oidcService;
|
||||
|
||||
public SettingsService(IUnitOfWork unitOfWork, IDirectoryService directoryService,
|
||||
ILibraryWatcher libraryWatcher, ITaskScheduler taskScheduler,
|
||||
ILogger<SettingsService> logger)
|
||||
ILogger<SettingsService> logger, IOidcService oidcService)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_directoryService = directoryService;
|
||||
_libraryWatcher = libraryWatcher;
|
||||
_taskScheduler = taskScheduler;
|
||||
_logger = logger;
|
||||
_oidcService = oidcService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -54,6 +76,7 @@ public class SettingsService : ISettingsService
|
||||
{
|
||||
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettings();
|
||||
existingMetadataSetting.Enabled = dto.Enabled;
|
||||
existingMetadataSetting.EnableExtendedMetadataProcessing = dto.EnableExtendedMetadataProcessing;
|
||||
existingMetadataSetting.EnableSummary = dto.EnableSummary;
|
||||
existingMetadataSetting.EnableLocalizedName = dto.EnableLocalizedName;
|
||||
existingMetadataSetting.EnablePublicationStatus = dto.EnablePublicationStatus;
|
||||
@ -108,6 +131,150 @@ public class SettingsService : ISettingsService
|
||||
return await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
}
|
||||
|
||||
public async Task<FieldMappingsImportResultDto> ImportFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings)
|
||||
{
|
||||
if (dto.AgeRatingMappings.Keys.Distinct().Count() != dto.AgeRatingMappings.Count)
|
||||
{
|
||||
throw new KavitaException("errors.import-fields.non-unique-age-ratings");
|
||||
}
|
||||
|
||||
if (dto.FieldMappings.DistinctBy(f => f.Id).Count() != dto.FieldMappings.Count)
|
||||
{
|
||||
throw new KavitaException("errors.import-fields.non-unique-fields");
|
||||
}
|
||||
|
||||
return settings.ImportMode switch
|
||||
{
|
||||
ImportMode.Merge => await MergeFieldMappings(dto, settings),
|
||||
ImportMode.Replace => await ReplaceFieldMappings(dto, settings),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(settings), $"Invalid import mode {nameof(settings.ImportMode)}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Will fully replace any enabled fields, always successful
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<FieldMappingsImportResultDto> ReplaceFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings)
|
||||
{
|
||||
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
|
||||
if (settings.Whitelist)
|
||||
{
|
||||
existingMetadataSetting.Whitelist = dto.Whitelist;
|
||||
}
|
||||
|
||||
if (settings.Blacklist)
|
||||
{
|
||||
existingMetadataSetting.Blacklist = dto.Blacklist;
|
||||
}
|
||||
|
||||
if (settings.AgeRatings)
|
||||
{
|
||||
existingMetadataSetting.AgeRatingMappings = dto.AgeRatingMappings;
|
||||
}
|
||||
|
||||
if (settings.FieldMappings)
|
||||
{
|
||||
existingMetadataSetting.FieldMappings = dto.FieldMappings;
|
||||
}
|
||||
|
||||
return new FieldMappingsImportResultDto
|
||||
{
|
||||
Success = true,
|
||||
ResultingMetadataSettings = existingMetadataSetting,
|
||||
AgeRatingConflicts = [],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to merge all enabled fields, fails if any merge was marked as manual. Always goes through all items
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <param name="settings"></param>
|
||||
/// <returns></returns>
|
||||
private async Task<FieldMappingsImportResultDto> MergeFieldMappings(FieldMappingsDto dto, ImportSettingsDto settings)
|
||||
{
|
||||
var existingMetadataSetting = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
|
||||
if (settings.Whitelist)
|
||||
{
|
||||
existingMetadataSetting.Whitelist = existingMetadataSetting.Whitelist.Union(dto.Whitelist).DistinctBy(d => d.ToNormalized()).ToList();
|
||||
}
|
||||
|
||||
if (settings.Blacklist)
|
||||
{
|
||||
existingMetadataSetting.Blacklist = existingMetadataSetting.Blacklist.Union(dto.Blacklist).DistinctBy(d => d.ToNormalized()).ToList();
|
||||
}
|
||||
|
||||
List<string> ageRatingConflicts = [];
|
||||
|
||||
if (settings.AgeRatings)
|
||||
{
|
||||
foreach (var arm in dto.AgeRatingMappings)
|
||||
{
|
||||
if (!existingMetadataSetting.AgeRatingMappings.TryGetValue(arm.Key, out var mapping))
|
||||
{
|
||||
existingMetadataSetting.AgeRatingMappings.Add(arm.Key, arm.Value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arm.Value == mapping)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var resolution = settings.AgeRatingConflictResolutions.GetValueOrDefault(arm.Key, settings.Resolution);
|
||||
|
||||
switch (resolution)
|
||||
{
|
||||
case ConflictResolution.Keep: continue;
|
||||
case ConflictResolution.Replace:
|
||||
existingMetadataSetting.AgeRatingMappings[arm.Key] = arm.Value;
|
||||
break;
|
||||
case ConflictResolution.Manual:
|
||||
ageRatingConflicts.Add(arm.Key);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException(nameof(settings), $"Invalid conflict resolution {nameof(ConflictResolution)}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (settings.FieldMappings)
|
||||
{
|
||||
existingMetadataSetting.FieldMappings = existingMetadataSetting.FieldMappings
|
||||
.Union(dto.FieldMappings)
|
||||
.DistinctBy(fm => new
|
||||
{
|
||||
fm.SourceType,
|
||||
SourceValue = fm.SourceValue.ToNormalized(),
|
||||
fm.DestinationType,
|
||||
DestinationValue = fm.DestinationValue.ToNormalized(),
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
if (ageRatingConflicts.Count > 0)
|
||||
{
|
||||
return new FieldMappingsImportResultDto
|
||||
{
|
||||
Success = false,
|
||||
AgeRatingConflicts = ageRatingConflicts,
|
||||
};
|
||||
}
|
||||
|
||||
return new FieldMappingsImportResultDto
|
||||
{
|
||||
Success = true,
|
||||
ResultingMetadataSettings = existingMetadataSetting,
|
||||
AgeRatingConflicts = [],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update Server Settings
|
||||
/// </summary>
|
||||
@ -135,6 +302,7 @@ public class SettingsService : ISettingsService
|
||||
}
|
||||
|
||||
var updateTask = false;
|
||||
var updatedOidcSettings = false;
|
||||
foreach (var setting in currentSettings)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.OnDeckProgressDays &&
|
||||
@ -172,7 +340,7 @@ public class SettingsService : ISettingsService
|
||||
updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
|
||||
|
||||
UpdateEmailSettings(setting, updateSettingsDto);
|
||||
|
||||
updatedOidcSettings = await UpdateOidcSettings(setting, updateSettingsDto) || updatedOidcSettings;
|
||||
|
||||
|
||||
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
|
||||
@ -324,6 +492,17 @@ public class SettingsService : ISettingsService
|
||||
BackgroundJob.Enqueue(() => _taskScheduler.ScheduleTasks());
|
||||
}
|
||||
|
||||
if (updatedOidcSettings)
|
||||
{
|
||||
Configuration.OidcSettings = new Configuration.OpenIdConnectSettings
|
||||
{
|
||||
Authority = updateSettingsDto.OidcConfig.Authority,
|
||||
ClientId = updateSettingsDto.OidcConfig.ClientId,
|
||||
Secret = updateSettingsDto.OidcConfig.Secret,
|
||||
CustomScopes = updateSettingsDto.OidcConfig.CustomScopes,
|
||||
};
|
||||
}
|
||||
|
||||
if (updateSettingsDto.EnableFolderWatching)
|
||||
{
|
||||
BackgroundJob.Enqueue(() => _libraryWatcher.StartWatching());
|
||||
@ -346,6 +525,29 @@ public class SettingsService : ISettingsService
|
||||
return updateSettingsDto;
|
||||
}
|
||||
|
||||
public async Task<bool> IsValidAuthority(string authority)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authority))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var hasTrailingSlash = authority.EndsWith('/');
|
||||
var url = authority + (hasTrailingSlash ? string.Empty : "/") + ".well-known/openid-configuration";
|
||||
|
||||
var json = await url.GetStringAsync();
|
||||
var config = OpenIdConnectConfiguration.Create(json);
|
||||
return config.Issuer == authority;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogDebug(e, "OpenIdConfiguration failed: {Reason}", e.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
|
||||
{
|
||||
_directoryService.ExistOrCreate(bookmarkDirectory);
|
||||
@ -379,6 +581,45 @@ public class SettingsService : ISettingsService
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates oidc settings and return true if a change was made
|
||||
/// </summary>
|
||||
/// <param name="setting"></param>
|
||||
/// <param name="updateSettingsDto"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Does not commit any changes</remarks>
|
||||
/// <exception cref="KavitaException">If the authority is invalid</exception>
|
||||
private async Task<bool> UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
if (setting.Key != ServerSettingKey.OidcConfiguration) return false;
|
||||
|
||||
if (updateSettingsDto.OidcConfig.RolesClaim.Trim() == string.Empty)
|
||||
{
|
||||
updateSettingsDto.OidcConfig.RolesClaim = ClaimTypes.Role;
|
||||
}
|
||||
|
||||
var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig);
|
||||
if (setting.Value == newValue) return false;
|
||||
|
||||
var currentConfig = JsonSerializer.Deserialize<OidcConfigDto>(setting.Value)!;
|
||||
|
||||
if (currentConfig.Authority != updateSettingsDto.OidcConfig.Authority)
|
||||
{
|
||||
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
|
||||
{
|
||||
throw new KavitaException("oidc-invalid-authority");
|
||||
}
|
||||
|
||||
_logger.LogWarning("OIDC Authority is changing, clearing all external ids");
|
||||
await _oidcService.ClearOidcIds();
|
||||
}
|
||||
|
||||
setting.Value = newValue;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.EmailHost &&
|
||||
|
59
API/Services/Store/CustomTicketStore.cs
Normal file
59
API/Services/Store/CustomTicketStore.cs
Normal file
@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace API.Services.Store;
|
||||
|
||||
public class CustomTicketStore(IMemoryCache cache): ITicketStore
|
||||
{
|
||||
|
||||
public async Task<string> StoreAsync(AuthenticationTicket ticket)
|
||||
{
|
||||
// Note: It might not be needed to make this cryptographic random, but better safe than sorry
|
||||
var bytes = new byte[32];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var key = Convert.ToBase64String(bytes);
|
||||
|
||||
await RenewAsync(key, ticket);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
public Task RenewAsync(string key, AuthenticationTicket ticket)
|
||||
{
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
Priority = CacheItemPriority.NeverRemove,
|
||||
Size = 1,
|
||||
};
|
||||
|
||||
var expiresUtc = ticket.Properties.ExpiresUtc;
|
||||
if (expiresUtc.HasValue)
|
||||
{
|
||||
options.AbsoluteExpiration = expiresUtc.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
options.SlidingExpiration = TimeSpan.FromDays(7);
|
||||
}
|
||||
|
||||
cache.Set(key, ticket, options);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<AuthenticationTicket> RetrieveAsync(string key)
|
||||
{
|
||||
return Task.FromResult(cache.Get<AuthenticationTicket>(key));
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string key)
|
||||
{
|
||||
cache.Remove(key);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.Data.Metadata;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Metadata;
|
||||
@ -29,7 +30,7 @@ namespace API.Services.Tasks.Scanner;
|
||||
|
||||
public interface IProcessSeries
|
||||
{
|
||||
Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false);
|
||||
Task ProcessSeriesAsync(MetadataSettingsDto settings, IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -70,7 +71,7 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
|
||||
|
||||
public async Task ProcessSeriesAsync(IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false)
|
||||
public async Task ProcessSeriesAsync(MetadataSettingsDto settings, IList<ParserInfo> parsedInfos, Library library, int totalToProcess, bool forceUpdate = false)
|
||||
{
|
||||
if (!parsedInfos.Any()) return;
|
||||
|
||||
@ -116,7 +117,7 @@ public class ProcessSeries : IProcessSeries
|
||||
// parsedInfos[0] is not the first volume or chapter. We need to find it using a ComicInfo check (as it uses firstParsedInfo for series sort)
|
||||
var firstParsedInfo = parsedInfos.FirstOrDefault(p => p.ComicInfo != null, firstInfo);
|
||||
|
||||
await UpdateVolumes(series, parsedInfos, forceUpdate);
|
||||
await UpdateVolumes(settings, series, parsedInfos, forceUpdate);
|
||||
series.Pages = series.Volumes.Sum(v => v.Pages);
|
||||
|
||||
series.NormalizedName = series.Name.ToNormalized();
|
||||
@ -151,7 +152,7 @@ public class ProcessSeries : IProcessSeries
|
||||
series.NormalizedLocalizedName = series.LocalizedName.ToNormalized();
|
||||
}
|
||||
|
||||
await UpdateSeriesMetadata(series, library);
|
||||
await UpdateSeriesMetadata(settings, series, library);
|
||||
|
||||
// Update series FolderPath here
|
||||
await UpdateSeriesFolderPath(parsedInfos, library, series);
|
||||
@ -288,7 +289,7 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
|
||||
|
||||
private async Task UpdateSeriesMetadata(Series series, Library library)
|
||||
private async Task UpdateSeriesMetadata(MetadataSettingsDto settings, Series series, Library library)
|
||||
{
|
||||
series.Metadata ??= new SeriesMetadataBuilder().Build();
|
||||
var firstChapter = SeriesService.GetFirstChapterForMetadata(series);
|
||||
@ -311,14 +312,16 @@ public class ProcessSeries : IProcessSeries
|
||||
{
|
||||
series.Metadata.AgeRating = chapters.Max(chapter => chapter.AgeRating);
|
||||
|
||||
// Get the MetadataSettings and apply Age Rating Mappings here
|
||||
var metadataSettings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
|
||||
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, metadataSettings.AgeRatingMappings);
|
||||
if (updatedRating > series.Metadata.AgeRating)
|
||||
if (settings.EnableExtendedMetadataProcessing)
|
||||
{
|
||||
series.Metadata.AgeRating = updatedRating;
|
||||
var allTags = series.Metadata.Tags.Select(t => t.Title).Concat(series.Metadata.Genres.Select(g => g.Title));
|
||||
var updatedRating = ExternalMetadataService.DetermineAgeRating(allTags, settings.AgeRatingMappings);
|
||||
if (updatedRating > series.Metadata.AgeRating)
|
||||
{
|
||||
series.Metadata.AgeRating = updatedRating;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
DeterminePublicationStatus(series, chapters);
|
||||
@ -340,16 +343,16 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
|
||||
#region PeopleAndTagsAndGenres
|
||||
if (!series.Metadata.WriterLocked)
|
||||
if (!series.Metadata.WriterLocked)
|
||||
{
|
||||
var personSw = Stopwatch.StartNew();
|
||||
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList();
|
||||
if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer))
|
||||
{
|
||||
var personSw = Stopwatch.StartNew();
|
||||
var chapterPeople = chapters.SelectMany(c => c.People.Where(p => p.Role == PersonRole.Writer)).ToList();
|
||||
if (ShouldUpdatePeopleForRole(series, chapterPeople, PersonRole.Writer))
|
||||
{
|
||||
await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer);
|
||||
}
|
||||
_logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count);
|
||||
await UpdateSeriesMetadataPeople(series.Metadata, series.Metadata.People, chapterPeople, PersonRole.Writer);
|
||||
}
|
||||
_logger.LogTrace("[TIME] Kavita took {Time} ms to process writer on Series: {File} for {Count} people", personSw.ElapsedMilliseconds, series.Name, chapterPeople.Count);
|
||||
}
|
||||
|
||||
if (!series.Metadata.ColoristLocked)
|
||||
{
|
||||
@ -676,7 +679,7 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateVolumes(Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
|
||||
private async Task UpdateVolumes(MetadataSettingsDto settings, Series series, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
|
||||
{
|
||||
// Add new volumes and update chapters per volume
|
||||
var distinctVolumes = parsedInfos.DistinctVolumes();
|
||||
@ -709,7 +712,7 @@ public class ProcessSeries : IProcessSeries
|
||||
|
||||
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
|
||||
|
||||
await UpdateChapters(series, volume, infos, forceUpdate);
|
||||
await UpdateChapters(settings, series, volume, infos, forceUpdate);
|
||||
volume.Pages = volume.Chapters.Sum(c => c.Pages);
|
||||
}
|
||||
|
||||
@ -746,7 +749,7 @@ public class ProcessSeries : IProcessSeries
|
||||
series.Volumes = nonDeletedVolumes;
|
||||
}
|
||||
|
||||
private async Task UpdateChapters(Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
|
||||
private async Task UpdateChapters(MetadataSettingsDto settings, Series series, Volume volume, IList<ParserInfo> parsedInfos, bool forceUpdate = false)
|
||||
{
|
||||
// Add new chapters
|
||||
foreach (var info in parsedInfos)
|
||||
@ -799,7 +802,7 @@ public class ProcessSeries : IProcessSeries
|
||||
|
||||
try
|
||||
{
|
||||
await UpdateChapterFromComicInfo(chapter, info.ComicInfo, forceUpdate);
|
||||
await UpdateChapterFromComicInfo(settings, chapter, info.ComicInfo, forceUpdate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@ -900,7 +903,7 @@ public class ProcessSeries : IProcessSeries
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateChapterFromComicInfo(Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false)
|
||||
private async Task UpdateChapterFromComicInfo(MetadataSettingsDto settings, Chapter chapter, ComicInfo? comicInfo, bool forceUpdate = false)
|
||||
{
|
||||
if (comicInfo == null) return;
|
||||
var firstFile = chapter.Files.MinBy(x => x.Chapter);
|
||||
@ -1069,16 +1072,25 @@ public class ProcessSeries : IProcessSeries
|
||||
await UpdateChapterPeopleAsync(chapter, people, PersonRole.Location);
|
||||
}
|
||||
|
||||
if (!chapter.GenresLocked)
|
||||
if (!chapter.GenresLocked || !chapter.TagsLocked)
|
||||
{
|
||||
var genres = TagHelper.GetTagValues(comicInfo.Genre);
|
||||
await UpdateChapterGenres(chapter, genres);
|
||||
}
|
||||
|
||||
if (!chapter.TagsLocked)
|
||||
{
|
||||
var tags = TagHelper.GetTagValues(comicInfo.Tags);
|
||||
await UpdateChapterTags(chapter, tags);
|
||||
|
||||
ExternalMetadataService.GenerateExternalGenreAndTagsList(genres, tags, settings,
|
||||
out var finalTags, out var finalGenres);
|
||||
|
||||
if (!chapter.GenresLocked)
|
||||
{
|
||||
await UpdateChapterGenres(chapter, finalGenres);
|
||||
}
|
||||
|
||||
if (!chapter.TagsLocked)
|
||||
{
|
||||
await UpdateChapterTags(chapter, finalTags);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
_logger.LogTrace("[TIME] Kavita took {Time} ms to create/update Chapter: {File}", sw.ElapsedMilliseconds, chapter.Files.First().FileName);
|
||||
|
@ -13,6 +13,7 @@ using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Plus;
|
||||
using API.Services.Tasks.Metadata;
|
||||
using API.Services.Tasks.Scanner;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
@ -316,7 +317,8 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
// Process Series
|
||||
var seriesProcessStopWatch = Stopwatch.StartNew();
|
||||
await _processSeries.ProcessSeriesAsync(parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks);
|
||||
var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
await _processSeries.ProcessSeriesAsync(settings, parsedSeries[pSeries], library, seriesLeftToProcess, bypassFolderOptimizationChecks);
|
||||
_logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, parsedSeries[pSeries][0].Series);
|
||||
seriesLeftToProcess--;
|
||||
}
|
||||
@ -614,6 +616,8 @@ public class ScannerService : IScannerService
|
||||
var toProcess = new Dictionary<ParsedSeries, IList<ParserInfo>>();
|
||||
var scanSw = Stopwatch.StartNew();
|
||||
|
||||
var settings = await _unitOfWork.SettingsRepository.GetMetadataSettingDto();
|
||||
|
||||
foreach (var series in parsedSeries)
|
||||
{
|
||||
if (!series.Key.HasChanged)
|
||||
@ -638,22 +642,26 @@ public class ScannerService : IScannerService
|
||||
var allGenres = toProcess
|
||||
.SelectMany(s => s.Value
|
||||
.SelectMany(p => p.ComicInfo?.Genre?
|
||||
.Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries
|
||||
.Select(g => g.Trim()) // Trim each genre
|
||||
.Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres
|
||||
?? [])); // Handle null Genre or ComicInfo safely
|
||||
|
||||
await CreateAllGenresAsync(allGenres.Distinct().ToList());
|
||||
.Split(",", StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(g => g.Trim())
|
||||
.Where(g => !string.IsNullOrWhiteSpace(g))
|
||||
?? []))
|
||||
.Distinct().ToList();
|
||||
|
||||
var allTags = toProcess
|
||||
.SelectMany(s => s.Value
|
||||
.SelectMany(p => p.ComicInfo?.Tags?
|
||||
.Split(",", StringSplitOptions.RemoveEmptyEntries) // Split on comma and remove empty entries
|
||||
.Select(g => g.Trim()) // Trim each genre
|
||||
.Where(g => !string.IsNullOrWhiteSpace(g)) // Ensure no null/empty genres
|
||||
?? [])); // Handle null Tag or ComicInfo safely
|
||||
.Split(",", StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(g => g.Trim())
|
||||
.Where(g => !string.IsNullOrWhiteSpace(g))
|
||||
?? []))
|
||||
.Distinct().ToList();
|
||||
|
||||
await CreateAllTagsAsync(allTags.Distinct().ToList());
|
||||
ExternalMetadataService.GenerateExternalGenreAndTagsList(allGenres, allTags, settings,
|
||||
out var processedTags, out var processedGenres);
|
||||
|
||||
await CreateAllGenresAsync(processedGenres);
|
||||
await CreateAllTagsAsync(processedTags);
|
||||
}
|
||||
|
||||
var totalFiles = 0;
|
||||
@ -664,7 +672,7 @@ public class ScannerService : IScannerService
|
||||
{
|
||||
totalFiles += pSeries.Value.Count;
|
||||
var seriesProcessStopWatch = Stopwatch.StartNew();
|
||||
await _processSeries.ProcessSeriesAsync(pSeries.Value, library, seriesLeftToProcess, forceUpdate);
|
||||
await _processSeries.ProcessSeriesAsync(settings, pSeries.Value, library, seriesLeftToProcess, forceUpdate);
|
||||
_logger.LogTrace("[TIME] Kavita took {Time} ms to process {SeriesName}", seriesProcessStopWatch.ElapsedMilliseconds, pSeries.Value[0].Series);
|
||||
seriesLeftToProcess--;
|
||||
}
|
||||
|
@ -248,7 +248,8 @@ public class StatsService : IStatsService
|
||||
DotnetVersion = Environment.Version.ToString(),
|
||||
OpdsEnabled = serverSettings.EnableOpds,
|
||||
EncodeMediaAs = serverSettings.EncodeMediaAs,
|
||||
MatchedMetadataEnabled = mediaSettings.Enabled
|
||||
MatchedMetadataEnabled = mediaSettings.Enabled,
|
||||
OidcEnabled = !string.IsNullOrEmpty(serverSettings.OidcConfig.Authority),
|
||||
};
|
||||
|
||||
dto.OsLocale = CultureInfo.CurrentCulture.EnglishName;
|
||||
@ -308,6 +309,7 @@ public class StatsService : IStatsService
|
||||
libDto.UsingFolderWatching = library.FolderWatching;
|
||||
libDto.CreateCollectionsFromMetadata = library.ManageCollections;
|
||||
libDto.CreateReadingListsFromMetadata = library.ManageReadingLists;
|
||||
libDto.EnabledMetadata = library.EnableMetadata;
|
||||
libDto.LibraryType = library.Type;
|
||||
|
||||
dto.Libraries.Add(libDto);
|
||||
@ -353,7 +355,9 @@ public class StatsService : IStatsService
|
||||
userDto.DevicePlatforms = user.Devices.Select(d => d.Platform).ToList();
|
||||
userDto.SeriesBookmarksCreatedCount = user.Bookmarks.Count;
|
||||
userDto.SmartFilterCreatedCount = user.SmartFilters.Count;
|
||||
userDto.IsSharingReviews = user.UserPreferences.ShareReviews;
|
||||
userDto.WantToReadSeriesCount = user.WantToRead.Count;
|
||||
userDto.IdentityProvider = user.IdentityProvider;
|
||||
|
||||
if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries))
|
||||
{
|
||||
|
@ -136,7 +136,7 @@ public class Startup
|
||||
}
|
||||
});
|
||||
services.AddCors();
|
||||
services.AddIdentityServices(_config);
|
||||
services.AddIdentityServices(_config, _env);
|
||||
services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new OpenApiInfo
|
||||
@ -296,6 +296,9 @@ public class Startup
|
||||
// v0.8.7
|
||||
await ManualMigrateReadingProfiles.Migrate(dataContext, logger);
|
||||
|
||||
// v0.8.8
|
||||
await ManualMigrateEnableMetadataMatchingDefault.Migrate(dataContext, unitOfWork, logger);
|
||||
|
||||
#endregion
|
||||
|
||||
// Update the version in the DB after all migrations are run
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
@ -14,6 +15,8 @@ public static class Configuration
|
||||
public const int DefaultHttpPort = 5000;
|
||||
public const int DefaultTimeOutSecs = 90;
|
||||
public const long DefaultCacheMemory = 75;
|
||||
public const string DefaultOidcAuthority = "";
|
||||
public const string DefaultOidcClientId = "kavita";
|
||||
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
|
||||
|
||||
public static readonly string KavitaPlusApiUrl = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == Environments.Development
|
||||
@ -50,6 +53,13 @@ public static class Configuration
|
||||
set => SetCacheSize(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
/// <remarks>You must set this object to update the settings, setting one if it's fields will not save to disk</remarks>
|
||||
public static OpenIdConnectSettings OidcSettings
|
||||
{
|
||||
get => GetOpenIdConnectSettings(GetAppSettingFilename());
|
||||
set => SetOpenIdConnectSettings(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename());
|
||||
|
||||
private static string GetAppSettingFilename()
|
||||
@ -312,6 +322,43 @@ public static class Configuration
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region OIDC
|
||||
|
||||
private static OpenIdConnectSettings GetOpenIdConnectSettings(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
|
||||
return jsonObj.OpenIdConnectSettings ?? new OpenIdConnectSettings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return new OpenIdConnectSettings();
|
||||
}
|
||||
|
||||
private static void SetOpenIdConnectSettings(string filePath, OpenIdConnectSettings value)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
jsonObj.OpenIdConnectSettings = value;
|
||||
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception */
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private sealed class AppSettings
|
||||
{
|
||||
public string TokenKey { get; set; }
|
||||
@ -326,6 +373,20 @@ public static class Configuration
|
||||
public long Cache { get; set; } = DefaultCacheMemory;
|
||||
// ReSharper disable once MemberHidesStaticFromOuterClass
|
||||
public bool AllowIFraming { get; init; } = false;
|
||||
public OpenIdConnectSettings OpenIdConnectSettings { get; set; } = new();
|
||||
#pragma warning restore S3218
|
||||
}
|
||||
|
||||
public class OpenIdConnectSettings
|
||||
{
|
||||
public string Authority { get; set; } = DefaultOidcAuthority;
|
||||
public string ClientId { get; set; } = DefaultOidcClientId;
|
||||
public string Secret { get; set; } = string.Empty;
|
||||
public List<string> CustomScopes { get; set; } = [];
|
||||
|
||||
public bool Enabled =>
|
||||
!string.IsNullOrEmpty(Authority) &&
|
||||
!string.IsNullOrEmpty(ClientId) &&
|
||||
!string.IsNullOrEmpty(Secret);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Company>kavitareader.com</Company>
|
||||
<Product>Kavita</Product>
|
||||
<AssemblyVersion>0.8.7.5</AssemblyVersion>
|
||||
<AssemblyVersion>0.8.7.7</AssemblyVersion>
|
||||
<NeutralLanguage>en</NeutralLanguage>
|
||||
<TieredPGO>true</TieredPGO>
|
||||
</PropertyGroup>
|
||||
|
@ -13,7 +13,8 @@
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
"style": "scss",
|
||||
"changeDetection": "OnPush"
|
||||
},
|
||||
"@schematics/angular:application": {
|
||||
"strict": true
|
||||
@ -98,7 +99,8 @@
|
||||
"sslKey": "./ssl/server.key",
|
||||
"sslCert": "./ssl/server.crt",
|
||||
"ssl": false,
|
||||
"buildTarget": "kavita-webui:build"
|
||||
"buildTarget": "kavita-webui:build",
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
15
UI/Web/package-lock.json
generated
15
UI/Web/package-lock.json
generated
@ -541,7 +541,6 @@
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-19.2.5.tgz",
|
||||
"integrity": "sha512-b2cG41r6lilApXLlvja1Ra2D00dM3BxmQhoElKC1tOnpD6S3/krlH1DOnBB2I55RBn9iv4zdmPz1l8zPUSh7DQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -569,7 +568,6 @@
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
|
||||
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@ -584,7 +582,6 @@
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
|
||||
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 14.16.0"
|
||||
},
|
||||
@ -4906,8 +4903,7 @@
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"dev": true
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
|
||||
},
|
||||
"node_modules/cosmiconfig": {
|
||||
"version": "8.3.6",
|
||||
@ -5354,7 +5350,6 @@
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
@ -5364,7 +5359,6 @@
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
@ -8181,8 +8175,7 @@
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||
"dev": true
|
||||
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="
|
||||
},
|
||||
"node_modules/replace-in-file": {
|
||||
"version": "7.1.0",
|
||||
@ -8403,7 +8396,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true
|
||||
"devOptional": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.85.0",
|
||||
@ -8468,7 +8461,6 @@
|
||||
"version": "7.7.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
|
||||
"integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
@ -9093,7 +9085,6 @@
|
||||
"version": "5.5.4",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
|
||||
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
37
UI/Web/proxy.conf.json
Normal file
37
UI/Web/proxy.conf.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/hubs": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"ws": true
|
||||
},
|
||||
"/oidc/login": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/oidc/logout": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug"
|
||||
},
|
||||
"/signin-oidc": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/signout-callback-oidc": {
|
||||
"target": "http://localhost:5000",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
@ -1,16 +1,21 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import { CanActivate, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
public urlKey: string = 'kavita--auth-intersection-url';
|
||||
|
||||
public static urlKey: string = 'kavita--auth-intersection-url';
|
||||
|
||||
baseURL = inject(APP_BASE_HREF);
|
||||
|
||||
constructor(private accountService: AccountService,
|
||||
private router: Router,
|
||||
private toastr: ToastrService,
|
||||
@ -23,7 +28,10 @@ export class AuthGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
localStorage.setItem(this.urlKey, window.location.pathname);
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/login' && !path.startsWith(this.baseURL + "registration") && path !== '') {
|
||||
localStorage.setItem(AuthGuard.urlKey, path);
|
||||
}
|
||||
this.router.navigateByUrl('/login');
|
||||
return false;
|
||||
})
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
@ -6,9 +6,14 @@ import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {translate, TranslocoService} from "@jsverse/transloco";
|
||||
import {AuthGuard} from "../_guards/auth.guard";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
|
||||
baseURL = inject(APP_BASE_HREF);
|
||||
|
||||
constructor(private router: Router, private toastr: ToastrService,
|
||||
private accountService: AccountService,
|
||||
private translocoService: TranslocoService) {}
|
||||
@ -26,7 +31,7 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
this.handleValidationError(error);
|
||||
break;
|
||||
case 401:
|
||||
this.handleAuthError(error);
|
||||
this.handleAuthError(request, error);
|
||||
break;
|
||||
case 404:
|
||||
this.handleNotFound(error);
|
||||
@ -114,19 +119,31 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
console.error('500 error:', error);
|
||||
}
|
||||
|
||||
private handleAuthError(error: any) {
|
||||
private handleAuthError(req: HttpRequest<unknown>, error: any) {
|
||||
// Special hack for register url, to not care about auth
|
||||
if (location.href.includes('/registration/confirm-email?token=')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/login' && !path.startsWith(this.baseURL+"registration") && path !== '') {
|
||||
localStorage.setItem(AuthGuard.urlKey, path);
|
||||
}
|
||||
|
||||
if (error.error && error.error !== 'Unauthorized') {
|
||||
this.toast(translate(error.error));
|
||||
}
|
||||
|
||||
// NOTE: Signin has error.error or error.statusText available.
|
||||
// if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334
|
||||
this.accountService.logout();
|
||||
|
||||
// Ensure AutoLogin is skipped when the OIDC endpoint is called
|
||||
this.accountService.logout(req.method === 'GET' && req.url.endsWith('/api/account'));
|
||||
}
|
||||
|
||||
// Assume the title is already translated
|
||||
private toast(message: string, title?: string) {
|
||||
if (message.startsWith('errors.')) {
|
||||
if ((message+'').startsWith('errors.')) {
|
||||
this.toastr.error(this.translocoService.translate(message), title);
|
||||
} else {
|
||||
this.toastr.error(message, title);
|
||||
|
@ -10,17 +10,15 @@ export class JwtInterceptor implements HttpInterceptor {
|
||||
constructor(private accountService: AccountService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return this.accountService.currentUser$.pipe(
|
||||
take(1),
|
||||
switchMap(user => {
|
||||
if (user) {
|
||||
request = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${user.token}`
|
||||
}
|
||||
});
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (user && user.token) {
|
||||
request = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${user.token}`
|
||||
}
|
||||
return next.handle(request);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return next.handle(request);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {AgeRestriction} from '../metadata/age-restriction';
|
||||
import {Library} from '../library/library';
|
||||
import {IdentityProvider} from "../user";
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
@ -13,4 +14,5 @@ export interface Member {
|
||||
libraries: Library[];
|
||||
ageRestriction: AgeRestriction;
|
||||
isPending: boolean;
|
||||
identityProvider: IdentityProvider;
|
||||
}
|
||||
|
32
UI/Web/src/app/_models/import-field-mappings.ts
Normal file
32
UI/Web/src/app/_models/import-field-mappings.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {MetadataSettings} from "../admin/_models/metadata-settings";
|
||||
|
||||
export enum ImportMode {
|
||||
Replace = 0,
|
||||
Merge = 1,
|
||||
}
|
||||
|
||||
export const ImportModes = [ImportMode.Replace, ImportMode.Merge];
|
||||
|
||||
export enum ConflictResolution {
|
||||
Manual = 0,
|
||||
Keep = 1,
|
||||
Replace = 2,
|
||||
}
|
||||
|
||||
export const ConflictResolutions = [ConflictResolution.Manual, ConflictResolution.Keep, ConflictResolution.Replace];
|
||||
|
||||
export interface ImportSettings {
|
||||
importMode: ImportMode;
|
||||
resolution: ConflictResolution;
|
||||
whitelist: boolean;
|
||||
blacklist: boolean;
|
||||
ageRatings: boolean;
|
||||
fieldMappings: boolean;
|
||||
ageRatingConflictResolutions: Record<string, ConflictResolution>;
|
||||
}
|
||||
|
||||
export interface FieldMappingsImportResult {
|
||||
success: boolean;
|
||||
resultingMetadataSettings: MetadataSettings;
|
||||
ageRatingConflicts: string[];
|
||||
}
|
@ -13,4 +13,12 @@ export interface User {
|
||||
ageRestriction: AgeRestriction;
|
||||
hasRunScrobbleEventGeneration: boolean;
|
||||
scrobbleEventGenerationRan: string; // datetime
|
||||
identityProvider: IdentityProvider,
|
||||
}
|
||||
|
||||
export enum IdentityProvider {
|
||||
Kavita = 0,
|
||||
OpenIdConnect = 1,
|
||||
}
|
||||
|
||||
export const IdentityProviders: IdentityProvider[] = [IdentityProvider.Kavita, IdentityProvider.OpenIdConnect];
|
||||
|
@ -12,13 +12,17 @@ export class AgeRatingPipe implements PipeTransform {
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: AgeRating | AgeRatingDto | undefined): string {
|
||||
transform(value: AgeRating | AgeRatingDto | undefined | string): string {
|
||||
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
||||
if (value.hasOwnProperty('title')) {
|
||||
return (value as AgeRatingDto).title;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = parseInt(value, 10) as AgeRating;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case AgeRating.Unknown:
|
||||
return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
26
UI/Web/src/app/_pipes/conflict-resolution.pipe.ts
Normal file
26
UI/Web/src/app/_pipes/conflict-resolution.pipe.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ConflictResolution} from "../_models/import-field-mappings";
|
||||
|
||||
@Pipe({
|
||||
name: 'conflictResolution'
|
||||
})
|
||||
export class ConflictResolutionPipe implements PipeTransform {
|
||||
|
||||
transform(value: ConflictResolution | null | string): string {
|
||||
if (typeof value === 'string') {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
switch (value) {
|
||||
case ConflictResolution.Manual:
|
||||
return translate('import-mappings.manual');
|
||||
case ConflictResolution.Keep:
|
||||
return translate('import-mappings.keep');
|
||||
case ConflictResolution.Replace:
|
||||
return translate('import-mappings.replace');
|
||||
}
|
||||
|
||||
return translate('common.unknown');
|
||||
}
|
||||
|
||||
}
|
19
UI/Web/src/app/_pipes/identity-provider.pipe.ts
Normal file
19
UI/Web/src/app/_pipes/identity-provider.pipe.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {IdentityProvider} from "../_models/user";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'identityProviderPipe'
|
||||
})
|
||||
export class IdentityProviderPipePipe implements PipeTransform {
|
||||
|
||||
transform(value: IdentityProvider): string {
|
||||
switch (value) {
|
||||
case IdentityProvider.Kavita:
|
||||
return translate("identity-provider-pipe.kavita");
|
||||
case IdentityProvider.OpenIdConnect:
|
||||
return translate("identity-provider-pipe.oidc");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
25
UI/Web/src/app/_pipes/import-mode.pipe.ts
Normal file
25
UI/Web/src/app/_pipes/import-mode.pipe.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {ImportMode} from "../_models/import-field-mappings";
|
||||
|
||||
@Pipe({
|
||||
name: 'importMode'
|
||||
})
|
||||
export class ImportModePipe implements PipeTransform {
|
||||
|
||||
transform(value: ImportMode | null | string): string {
|
||||
if (typeof value === 'string') {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case ImportMode.Replace:
|
||||
return translate('import-mappings.replace');
|
||||
case ImportMode.Merge:
|
||||
return translate('import-mappings.merge');
|
||||
}
|
||||
|
||||
return translate('common.unknown');
|
||||
}
|
||||
|
||||
}
|
25
UI/Web/src/app/_pipes/metadata-field-type.pipe.ts
Normal file
25
UI/Web/src/app/_pipes/metadata-field-type.pipe.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {Pipe, PipeTransform} from '@angular/core';
|
||||
import {MetadataFieldType} from "../admin/_models/metadata-settings";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'metadataFieldType'
|
||||
})
|
||||
export class MetadataFieldTypePipe implements PipeTransform {
|
||||
|
||||
transform(value: MetadataFieldType | null | string): string {
|
||||
if (typeof value === 'string') {
|
||||
value = parseInt(value, 10);
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case MetadataFieldType.Genre:
|
||||
return translate('manage-metadata-settings.genre');
|
||||
case MetadataFieldType.Tag:
|
||||
return translate('manage-metadata-settings.tag');
|
||||
default:
|
||||
return translate('common.unknown');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||
import {DestroyRef, inject, Injectable} from '@angular/core';
|
||||
import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
|
||||
import {filter, map, switchMap, tap} from 'rxjs/operators';
|
||||
@ -13,7 +13,7 @@ import {UserUpdateEvent} from '../_models/events/user-update-event';
|
||||
import {AgeRating} from '../_models/metadata/age-rating';
|
||||
import {AgeRestriction} from '../_models/metadata/age-restriction';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
|
||||
import {Action} from "./action-factory.service";
|
||||
import {LicenseService} from "./license.service";
|
||||
import {LocalizationService} from "./localization.service";
|
||||
@ -63,7 +63,7 @@ export class AccountService {
|
||||
return this.hasAdminRole(u);
|
||||
}), shareReplay({bufferSize: 1, refCount: true}));
|
||||
|
||||
|
||||
public readonly currentUserSignal = toSignal(this.currentUserSource);
|
||||
|
||||
/**
|
||||
* SetTimeout handler for keeping track of refresh token call
|
||||
@ -205,14 +205,22 @@ export class AccountService {
|
||||
);
|
||||
}
|
||||
|
||||
getAccount() {
|
||||
return this.httpClient.get<User>(this.baseUrl + 'account').pipe(
|
||||
tap((response: User) => {
|
||||
const user = response;
|
||||
if (user) {
|
||||
this.setCurrentUser(user);
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentUser(user?: User, refreshConnections = true) {
|
||||
|
||||
const isSameUser = this.currentUser === user;
|
||||
if (user) {
|
||||
user.roles = [];
|
||||
const roles = this.getDecodedToken(user.token).role;
|
||||
Array.isArray(roles) ? user.roles = roles : user.roles.push(roles);
|
||||
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
localStorage.setItem(AccountService.lastLoginKey, user.username);
|
||||
|
||||
@ -240,18 +248,30 @@ export class AccountService {
|
||||
this.messageHub.createHubConnection(this.currentUser);
|
||||
this.licenseService.hasValidLicense().subscribe();
|
||||
}
|
||||
this.startRefreshTokenTimer();
|
||||
if (this.currentUser.token) {
|
||||
this.startRefreshTokenTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
logout(skipAutoLogin: boolean = false) {
|
||||
const user = this.currentUserSignal();
|
||||
if (!user) return;
|
||||
|
||||
localStorage.removeItem(this.userKey);
|
||||
this.currentUserSource.next(undefined);
|
||||
this.currentUser = undefined;
|
||||
this.stopRefreshTokenTimer();
|
||||
this.messageHub.stopHubConnection();
|
||||
// Upon logout, perform redirection
|
||||
this.router.navigateByUrl('/login');
|
||||
|
||||
if (!user.token) {
|
||||
window.location.href = '/oidc/logout';
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {skipAutoLogin: skipAutoLogin}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -269,6 +289,11 @@ export class AccountService {
|
||||
);
|
||||
}
|
||||
|
||||
isOidcAuthenticated() {
|
||||
return this.httpClient.get<string>(this.baseUrl + 'account/oidc-authenticated', TextResonse)
|
||||
.pipe(map(res => res == "true"));
|
||||
}
|
||||
|
||||
isEmailConfirmed() {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed');
|
||||
}
|
||||
@ -410,7 +435,8 @@ export class AccountService {
|
||||
|
||||
|
||||
private refreshToken() {
|
||||
if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of();
|
||||
if (this.currentUser === null || this.currentUser === undefined || !this.isOnline || !this.currentUser.token) return of();
|
||||
|
||||
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
|
||||
{token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
|
||||
if (this.currentUser) {
|
||||
|
@ -4,6 +4,7 @@ import {catchError, map, ReplaySubject, tap, throwError} from "rxjs";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {LicenseInfo} from "../_models/kavitaplus/license-info";
|
||||
import {toSignal} from "@angular/core/rxjs-interop";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -18,6 +19,7 @@ export class LicenseService {
|
||||
* Does the user have an active license
|
||||
*/
|
||||
public readonly hasValidLicense$ = this.hasValidLicenseSource.asObservable();
|
||||
public readonly hasValidLicenseSignal = toSignal(this.hasValidLicense$, {initialValue: false});
|
||||
|
||||
|
||||
/**
|
||||
|
@ -11,6 +11,8 @@ import {NavigationEnd, Router} from "@angular/router";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
|
||||
import {WikiLink} from "../_models/wiki";
|
||||
import {AuthGuard} from "../_guards/auth.guard";
|
||||
import {SettingsService} from "../admin/settings.service";
|
||||
|
||||
/**
|
||||
* NavItem used to construct the dropdown or NavLinkModal on mobile
|
||||
@ -173,10 +175,24 @@ export class NavService {
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.accountService.logout();
|
||||
this.hideNavBar();
|
||||
this.hideSideNav();
|
||||
this.router.navigateByUrl('/login');
|
||||
this.accountService.logout();
|
||||
}
|
||||
|
||||
handleLogin() {
|
||||
this.showNavBar();
|
||||
this.showSideNav();
|
||||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem(AuthGuard.urlKey);
|
||||
if (pageResume && pageResume !== '/login') {
|
||||
localStorage.setItem(AuthGuard.urlKey, '');
|
||||
this.router.navigateByUrl(pageResume);
|
||||
} else {
|
||||
localStorage.setItem(AuthGuard.urlKey, '');
|
||||
this.router.navigateByUrl('/home');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -18,6 +18,7 @@ export interface MetadataFieldMapping {
|
||||
|
||||
export interface MetadataSettings {
|
||||
enabled: boolean;
|
||||
enableExtendedMetadataProcessing: boolean;
|
||||
enableSummary: boolean;
|
||||
enablePublicationStatus: boolean;
|
||||
enableRelationships: boolean;
|
||||
@ -36,7 +37,7 @@ export interface MetadataSettings {
|
||||
enableGenres: boolean;
|
||||
enableTags: boolean;
|
||||
firstLastPeopleNaming: boolean;
|
||||
ageRatingMappings: Map<string, AgeRating>;
|
||||
ageRatingMappings: Record<string, AgeRating>;
|
||||
fieldMappings: Array<MetadataFieldMapping>;
|
||||
blacklist: Array<string>;
|
||||
whitelist: Array<string>;
|
||||
|
25
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
25
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
|
||||
export interface OidcPublicConfig {
|
||||
autoLogin: boolean;
|
||||
disablePasswordAuthentication: boolean;
|
||||
providerName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface OidcConfig extends OidcPublicConfig {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
secret: string;
|
||||
provisionAccounts: boolean;
|
||||
requireVerifiedEmail: boolean;
|
||||
syncUserSettings: boolean;
|
||||
rolesPrefix: string;
|
||||
rolesClaim: string;
|
||||
customScopes: string[];
|
||||
defaultRoles: string[];
|
||||
defaultLibraries: number[];
|
||||
defaultAgeRestriction: AgeRating;
|
||||
defaultIncludeUnknowns: boolean;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {EncodeFormat} from "./encode-format";
|
||||
import {CoverImageSize} from "./cover-image-size";
|
||||
import {SmtpConfig} from "./smtp-config";
|
||||
import {OidcConfig} from "./oidc-config";
|
||||
|
||||
export interface ServerSettings {
|
||||
cacheDirectory: string;
|
||||
@ -25,6 +26,7 @@ export interface ServerSettings {
|
||||
onDeckUpdateDays: number;
|
||||
coverImageSize: CoverImageSize;
|
||||
smtpConfig: SmtpConfig;
|
||||
oidcConfig: OidcConfig;
|
||||
installId: string;
|
||||
installVersion: string;
|
||||
}
|
||||
|
@ -1,17 +1,41 @@
|
||||
<ng-container *transloco="let t; read: 'edit-user'">
|
||||
<ng-container *transloco="let t; prefix: 'edit-user'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member().username | sentenceCase}}</h5>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
||||
@if (!isLocked() && member().identityProvider === IdentityProvider.OpenIdConnect) {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('out-of-sync')}}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLocked()) {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('oidc-managed')}}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="userForm">
|
||||
<h4>{{t('account-detail-title')}}</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12 pe-4">
|
||||
<div class="col-md-4 col-sm-12 pe-4">
|
||||
@if (userForm.get('identityProvider'); as formControl) {
|
||||
<label for="identityProvider" class="form-label">{{t('identity-provider')}}</label>
|
||||
<select class="form-select" id="identityProvider" formControlName="identityProvider">
|
||||
@for (idp of IdentityProviders; track idp) {
|
||||
<option [value]="idp">{{idp | identityProviderPipe}}</option>
|
||||
}
|
||||
</select>
|
||||
<span class="text-muted">{{t('identity-provider-tooltip')}}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-4">
|
||||
@if(userForm.get('username'); as formControl) {
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{t('username')}}</label>
|
||||
@ -33,7 +57,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
@if(userForm.get('email'); as formControl) {
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
@ -63,17 +87,17 @@
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member()"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member()"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member()"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@ -83,7 +107,7 @@
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isLocked() || isSaving || !userForm.valid">
|
||||
@if (isSaving) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
|
@ -0,0 +1,3 @@
|
||||
.text-muted {
|
||||
font-size: 0.75rem;
|
||||
}
|
@ -1,4 +1,13 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
model,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
|
||||
@ -9,20 +18,23 @@ import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
|
||||
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
|
||||
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
|
||||
import {RoleSelectorComponent} from '../role-selector/role-selector.component';
|
||||
import {AsyncPipe, NgIf} from '@angular/common';
|
||||
import {AsyncPipe} from '@angular/common';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {debounceTime, distinctUntilChanged, Observable, startWith, switchMap, tap} from "rxjs";
|
||||
import {debounceTime, distinctUntilChanged, Observable, startWith, tap} from "rxjs";
|
||||
import {map} from "rxjs/operators";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {ServerSettings} from "../_models/server-settings";
|
||||
import {IdentityProvider, IdentityProviders} from "../../_models/user";
|
||||
import {IdentityProviderPipePipe} from "../../_pipes/identity-provider.pipe";
|
||||
|
||||
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
|
||||
const AllowedUsernameCharacters = /^[a-zA-Z0-9\-._@+/]*$/;
|
||||
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-user',
|
||||
templateUrl: './edit-user.component.html',
|
||||
styleUrls: ['./edit-user.component.scss'],
|
||||
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe],
|
||||
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, IdentityProviderPipePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
@ -32,7 +44,14 @@ export class EditUserComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input({required: true}) member!: Member;
|
||||
member = model.required<Member>();
|
||||
settings = model.required<ServerSettings>();
|
||||
|
||||
isLocked = computed(() => {
|
||||
const setting = this.settings();
|
||||
const member = this.member();
|
||||
return setting.oidcConfig.syncUserSettings && member.identityProvider === IdentityProvider.OpenIdConnect;
|
||||
});
|
||||
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
@ -52,18 +71,29 @@ export class EditUserComponent implements OnInit {
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required]));
|
||||
this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
|
||||
this.userForm.addControl('email', new FormControl(this.member().email, [Validators.required]));
|
||||
this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
|
||||
this.userForm.addControl('identityProvider', new FormControl(this.member().identityProvider, [Validators.required]));
|
||||
|
||||
this.userForm.get('identityProvider')!.valueChanges.pipe(
|
||||
tap(value => {
|
||||
const newIdentityProvider = parseInt(value, 10) as IdentityProvider;
|
||||
if (newIdentityProvider === IdentityProvider.OpenIdConnect) return;
|
||||
this.member.set({
|
||||
...this.member(),
|
||||
identityProvider: newIdentityProvider,
|
||||
})
|
||||
})).subscribe();
|
||||
|
||||
this.isEmailInvalid$ = this.userForm.get('email')!.valueChanges.pipe(
|
||||
startWith(this.member.email),
|
||||
startWith(this.member().email),
|
||||
distinctUntilChanged(),
|
||||
debounceTime(10),
|
||||
map(value => !EmailRegex.test(value)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
);
|
||||
|
||||
this.selectedRestriction = this.member.ageRestriction;
|
||||
this.selectedRestriction = this.member().ageRestriction;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
@ -88,14 +118,23 @@ export class EditUserComponent implements OnInit {
|
||||
|
||||
save() {
|
||||
const model = this.userForm.getRawValue();
|
||||
model.userId = this.member.id;
|
||||
model.userId = this.member().id;
|
||||
model.roles = this.selectedRoles;
|
||||
model.libraries = this.selectedLibraries;
|
||||
model.ageRestriction = this.selectedRestriction;
|
||||
model.identityProvider = parseInt(model.identityProvider, 10) as IdentityProvider;
|
||||
|
||||
this.accountService.update(model).subscribe(() => {
|
||||
this.modal.close(true);
|
||||
|
||||
this.accountService.update(model).subscribe({
|
||||
next: () => {
|
||||
this.modal.close(true);
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly IdentityProvider = IdentityProvider;
|
||||
protected readonly IdentityProviders = IdentityProviders;
|
||||
}
|
||||
|
@ -0,0 +1,171 @@
|
||||
<ng-container *transloco="let t; prefix: 'import-mappings'">
|
||||
|
||||
<div class="row g-0" style="min-width: 135px;">
|
||||
<app-step-tracker [steps]="steps" [currentStep]="currentStepIndex()"></app-step-tracker>
|
||||
</div>
|
||||
|
||||
<app-loading [loading]="isLoading()" />
|
||||
@if (!isLoading()) {
|
||||
<div>
|
||||
@switch (currentStepIndex()) {
|
||||
@case (Step.Import) {
|
||||
<div class="row g-0">
|
||||
<p>{{t('import-description')}}</p>
|
||||
<form [formGroup]="uploadForm" enctype="multipart/form-data">
|
||||
<file-upload [multiple]="false" formControlName="files"></file-upload>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
@case (Step.Configure) {
|
||||
<form class="row" [formGroup]="importSettingsForm">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
@if (importSettingsForm.get('importMode'); as control) {
|
||||
<app-setting-item [control]="control" [canEdit]="false" [showEdit]="false" [title]="t('import-mode-label')" [subtitle]="t('import-mode-tooltip')">
|
||||
<ng-template #view>
|
||||
<select formControlName="importMode" class="form-control">
|
||||
@for (mode of ImportModes; track mode) {
|
||||
<option [ngValue]="mode">{{mode | importMode}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
@if (importSettingsForm.get('resolution'); as control) {
|
||||
<app-setting-item [control]="control" [canEdit]="false" [showEdit]="false" [title]="t('resolution-label')" [subtitle]="t('resolution-tooltip')">
|
||||
<ng-template #view>
|
||||
<select formControlName="resolution" class="form-control">
|
||||
@for (resolution of ConflictResolutions; track resolution) {
|
||||
<option [ngValue]="resolution">{{resolution | conflictResolution}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="conflict-group-title pt-4">{{t('fields-to-import')}}</div>
|
||||
<div class="text-muted">{{t('fields-to-import-tooltip')}}</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12 pt-4">
|
||||
@if (importSettingsForm.get('whitelist'); as control) {
|
||||
<app-setting-switch [title]="t('whitelist-label')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch">
|
||||
<input id="whitelist-enabled" type="checkbox" class="form-check-input" formControlName="whitelist">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12 pt-4">
|
||||
@if (importSettingsForm.get('blacklist'); as control) {
|
||||
<app-setting-switch [title]="t('blacklist-label')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch">
|
||||
<input id="blacklist-enabled" type="checkbox" class="form-check-input" formControlName="blacklist">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12 pt-4">
|
||||
@if (importSettingsForm.get('ageRatings'); as control) {
|
||||
<app-setting-switch [title]="t('age-ratings-label')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch">
|
||||
<input id="age-ratings-enabled" type="checkbox" class="form-check-input" formControlName="ageRatings">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12 pt-4">
|
||||
@if (importSettingsForm.get('fieldMappings'); as control) {
|
||||
<app-setting-switch [title]="t('field-mappings-label')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch">
|
||||
<input id="field-mappings-enabled" type="checkbox" class="form-check-input" formControlName="fieldMappings">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
}
|
||||
@case (Step.Conflicts) {
|
||||
@let res = importResult();
|
||||
@if (res) {
|
||||
<form class="row" [formGroup]="importSettingsForm">
|
||||
|
||||
@if (res.ageRatingConflicts.length > 0) {
|
||||
<div class="conflict-group-title">{{t('age-ratings-label')}}</div>
|
||||
<div class="text-muted">{{t('age-ratings-conflicts-tooltip')}}</div>
|
||||
}
|
||||
|
||||
@for (arm of res.ageRatingConflicts; track arm) {
|
||||
<div class="col-md-6 col-sm-12 pt-4">
|
||||
@if (importSettingsForm.get('ageRatingConflictResolutions.' + arm); as control) {
|
||||
<div formGroupName="ageRatingConflictResolutions">
|
||||
<span class="conflict-title">{{arm}}</span>
|
||||
<select [formControlName]="arm" class="form-control mt-2">
|
||||
@for (resolution of ConflictResolutions; track resolution) {
|
||||
<option [ngValue]="resolution">
|
||||
<ng-container [ngTemplateOutlet]="ageRatingConflict"
|
||||
[ngTemplateOutletContext]="{$implicit: arm, resolution: resolution }" >
|
||||
</ng-container>
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@case (Step.Finalize) {
|
||||
@let res = importResult();
|
||||
@if (res) {
|
||||
<app-manage-metadata-mappings
|
||||
[settings]="res.resultingMetadataSettings"
|
||||
[settingsForm]="mappingsForm"
|
||||
[showHeader]="false">
|
||||
</app-manage-metadata-mappings>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="modal-footer mt-3">
|
||||
<div class="col-auto ms-1">
|
||||
<button type="button" class="btn btn-secondary" (click)="prevStep()" [disabled]="!canMoveToPrevStep()">{{t('prev')}}</button>
|
||||
</div>
|
||||
<div class="col-auto ms-1">
|
||||
<button type="button" class="btn btn-primary" (click)="nextStep()" [disabled]="!canMoveToNextStep()">{{t(nextButtonLabel())}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<ng-template #ageRatingConflict let-arm let-resolution='resolution'>
|
||||
@let oldValue = settings()!.ageRatingMappings[arm];
|
||||
@let newValue = importedMappings()!.ageRatingMappings[arm];
|
||||
|
||||
@switch (resolution) {
|
||||
@case (ConflictResolution.Manual) { {{'import-mappings.to-pick' | transloco}} }
|
||||
@case (ConflictResolution.Keep) { {{ oldValue | ageRating }} }
|
||||
@case (ConflictResolution.Replace) { {{ newValue | ageRating }} }
|
||||
}
|
||||
</ng-template>
|
@ -0,0 +1,50 @@
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heading-badge {
|
||||
color: var(--bs-badge-color);
|
||||
}
|
||||
|
||||
::ng-deep .file-info {
|
||||
width: 83%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
::ng-deep .file-buttons {
|
||||
float: right;
|
||||
}
|
||||
|
||||
file-upload {
|
||||
background: none;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
::ng-deep .upload-input {
|
||||
color: var(--input-text-color) !important;
|
||||
}
|
||||
|
||||
::ng-deep file-upload-list-item {
|
||||
color: var(--input-text-color) !important;
|
||||
}
|
||||
|
||||
.conflict-group-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--accent-text-color);
|
||||
}
|
||||
|
||||
.conflict-title {
|
||||
font-size: 1.2rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.reset-color {
|
||||
color: var(--accent-text-color);
|
||||
}
|
||||
|
||||
.break {
|
||||
height: 1px;
|
||||
background-color: var(--setting-break-color);
|
||||
margin: 10px 0;
|
||||
}
|
@ -0,0 +1,307 @@
|
||||
import {Component, computed, inject, OnInit, signal, ViewChild} from '@angular/core';
|
||||
import {translate, TranslocoDirective, TranslocoPipe} from "@jsverse/transloco";
|
||||
import {StepTrackerComponent, TimelineStep} from "../../reading-list/_components/step-tracker/step-tracker.component";
|
||||
import {WikiLink} from "../../_models/wiki";
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ValidatorFn,
|
||||
Validators
|
||||
} from "@angular/forms";
|
||||
import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload";
|
||||
import {MetadataSettings} from "../_models/metadata-settings";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {
|
||||
ManageMetadataMappingsComponent,
|
||||
MetadataMappingsExport
|
||||
} from "../manage-metadata-mappings/manage-metadata-mappings.component";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {ImportModePipe} from "../../_pipes/import-mode.pipe";
|
||||
import {ConflictResolutionPipe} from "../../_pipes/conflict-resolution.pipe";
|
||||
import {
|
||||
ConflictResolution,
|
||||
ConflictResolutions,
|
||||
FieldMappingsImportResult,
|
||||
ImportMode,
|
||||
ImportModes,
|
||||
ImportSettings
|
||||
} from "../../_models/import-field-mappings";
|
||||
import {firstValueFrom, switchMap} from "rxjs";
|
||||
import {tap} from "rxjs/operators";
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {NgTemplateOutlet} from "@angular/common";
|
||||
import {Router} from "@angular/router";
|
||||
import {LicenseService} from "../../_services/license.service";
|
||||
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
|
||||
|
||||
enum Step {
|
||||
Import = 0,
|
||||
Configure = 1,
|
||||
Conflicts = 2,
|
||||
Finalize = 3,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-import-mappings',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
StepTrackerComponent,
|
||||
FileUploadComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
LoadingComponent,
|
||||
SettingSwitchComponent,
|
||||
SettingItemComponent,
|
||||
ImportModePipe,
|
||||
ConflictResolutionPipe,
|
||||
AgeRatingPipe,
|
||||
NgTemplateOutlet,
|
||||
TranslocoPipe,
|
||||
ManageMetadataMappingsComponent,
|
||||
],
|
||||
templateUrl: './import-mappings.component.html',
|
||||
styleUrl: './import-mappings.component.scss'
|
||||
})
|
||||
export class ImportMappingsComponent implements OnInit {
|
||||
|
||||
private readonly router = inject(Router);
|
||||
private readonly licenseService = inject(LicenseService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
@ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent;
|
||||
|
||||
steps: TimelineStep[] = [
|
||||
{title: translate('import-mappings.import-step'), index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'},
|
||||
{title: translate('import-mappings.configure-step'), index: Step.Configure, active: false, icon: 'fa-solid fa-gears'},
|
||||
{title: translate('import-mappings.conflicts-step'), index: Step.Conflicts, active: false, icon: 'fa-solid fa-hammer'},
|
||||
{title: translate('import-mappings.finalize-step'), index: Step.Finalize, active: false, icon: 'fa-solid fa-floppy-disk'},
|
||||
];
|
||||
currentStepIndex = signal(this.steps[0].index);
|
||||
|
||||
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
|
||||
FileUploadValidators.accept(['.json']), FileUploadValidators.filesLimit(1)
|
||||
]);
|
||||
|
||||
uploadForm = new FormGroup({
|
||||
files: this.fileUploadControl,
|
||||
});
|
||||
importSettingsForm = new FormGroup({
|
||||
importMode: new FormControl(ImportMode.Merge, [Validators.required]),
|
||||
resolution: new FormControl(ConflictResolution.Manual),
|
||||
whitelist: new FormControl(true),
|
||||
blacklist: new FormControl(true),
|
||||
ageRatings: new FormControl(true),
|
||||
fieldMappings: new FormControl(true),
|
||||
ageRatingConflictResolutions: new FormGroup({}),
|
||||
});
|
||||
/**
|
||||
* This is that contains the data in the finalize step
|
||||
*/
|
||||
mappingsForm = new FormGroup({});
|
||||
|
||||
isLoading = signal(false);
|
||||
settings = signal<MetadataSettings | undefined>(undefined)
|
||||
importedMappings = signal<MetadataMappingsExport | undefined>(undefined);
|
||||
importResult = signal<FieldMappingsImportResult | undefined>(undefined);
|
||||
|
||||
nextButtonLabel = computed(() => {
|
||||
switch(this.currentStepIndex()) {
|
||||
case Step.Configure:
|
||||
case Step.Conflicts:
|
||||
return 'import';
|
||||
case Step.Finalize:
|
||||
return 'save';
|
||||
default:
|
||||
return 'next';
|
||||
}
|
||||
});
|
||||
|
||||
canMoveToNextStep = computed(() => {
|
||||
switch (this.currentStepIndex()) {
|
||||
case Step.Import:
|
||||
return this.isFileSelected();
|
||||
case Step.Finalize:
|
||||
case Step.Configure:
|
||||
return true;
|
||||
case Step.Conflicts:
|
||||
return this.importSettingsForm.valid;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
canMoveToPrevStep = computed(() => {
|
||||
switch (this.currentStepIndex()) {
|
||||
case Step.Import:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.getMetadataSettings().subscribe((settings) => {
|
||||
this.settings.set(settings);
|
||||
});
|
||||
}
|
||||
|
||||
async nextStep() {
|
||||
if (this.currentStepIndex() === Step.Import && !this.isFileSelected()) return;
|
||||
|
||||
this.isLoading.set(true);
|
||||
try {
|
||||
switch(this.currentStepIndex()) {
|
||||
case Step.Import:
|
||||
await this.validateImport();
|
||||
break;
|
||||
case Step.Conflicts:
|
||||
case Step.Configure:
|
||||
await this.tryImport();
|
||||
break;
|
||||
case Step.Finalize:
|
||||
this.save();
|
||||
}
|
||||
} catch (error) {
|
||||
/** Swallow **/
|
||||
}
|
||||
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
|
||||
save() {
|
||||
const res = this.importResult();
|
||||
if (!res) return;
|
||||
|
||||
const newSettings = res.resultingMetadataSettings;
|
||||
const data = this.manageMetadataMappingsComponent.packData();
|
||||
|
||||
// Update settings with data from the final step
|
||||
newSettings.whitelist = data.whitelist;
|
||||
newSettings.blacklist = data.blacklist;
|
||||
newSettings.ageRatingMappings = data.ageRatingMappings;
|
||||
newSettings.fieldMappings = data.fieldMappings;
|
||||
|
||||
this.settingsService.updateMetadataSettings(newSettings).subscribe({
|
||||
next: () => {
|
||||
const fragment = this.licenseService.hasValidLicenseSignal()
|
||||
? SettingsTabId.Metadata : SettingsTabId.ManageMetadata;
|
||||
|
||||
this.router.navigate(['settings'], { fragment: fragment });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async tryImport() {
|
||||
const data = this.importedMappings();
|
||||
if (!data) {
|
||||
this.toastr.error(translate('import-mappings.file-no-valid-content'));
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const settings = this.importSettingsForm.value as ImportSettings;
|
||||
|
||||
return firstValueFrom(this.settingsService.importFieldMappings(data, settings).pipe(
|
||||
tap((res) => this.importResult.set(res)),
|
||||
switchMap((res) => {
|
||||
return this.settingsService.getMetadataSettings().pipe(
|
||||
tap(dto => this.settings.set(dto)),
|
||||
tap(() => {
|
||||
if (res.success) {
|
||||
this.currentStepIndex.set(Step.Finalize);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setupSettingConflicts(res);
|
||||
this.currentStepIndex.set(Step.Conflicts);
|
||||
}),
|
||||
)}),
|
||||
));
|
||||
}
|
||||
|
||||
async validateImport() {
|
||||
const files = this.fileUploadControl.value;
|
||||
if (!files || files.length === 0) {
|
||||
this.toastr.error(translate('import-mappings.select-files-warning'));
|
||||
return;
|
||||
}
|
||||
|
||||
const file = files[0];
|
||||
let newImport: MetadataMappingsExport;
|
||||
try {
|
||||
newImport = JSON.parse(await file.text()) as MetadataMappingsExport;
|
||||
} catch (error) {
|
||||
this.toastr.error(translate('import-mappings.invalid-file'));
|
||||
return;
|
||||
}
|
||||
if (!newImport.fieldMappings && !newImport.ageRatingMappings && !newImport.blacklist && !newImport.whitelist) {
|
||||
this.toastr.error(translate('import-mappings.file-no-valid-content'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.importedMappings.set(newImport);
|
||||
this.currentStepIndex.update(x=>x + 1);
|
||||
}
|
||||
|
||||
private setupSettingConflicts(res: FieldMappingsImportResult) {
|
||||
const ageRatingGroup = this.importSettingsForm.get('ageRatingConflictResolutions')! as FormGroup;
|
||||
|
||||
for (let key of res.ageRatingConflicts) {
|
||||
if (!ageRatingGroup.get(key)) {
|
||||
ageRatingGroup.addControl(key, new FormControl(ConflictResolution.Manual, [this.notManualValidator()]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private notManualValidator(): ValidatorFn {
|
||||
return (control: AbstractControl) => {
|
||||
const value = control.value;
|
||||
try {
|
||||
if (parseInt(value, 10) !== ConflictResolution.Manual) return null;
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
return {'notManualValidator': {'value': value}}
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStepIndex() === Step.Import) return;
|
||||
|
||||
if (this.currentStepIndex() === Step.Finalize) {
|
||||
if (this.importResult()!.ageRatingConflicts.length === 0) {
|
||||
this.currentStepIndex.set(Step.Configure);
|
||||
} else {
|
||||
this.currentStepIndex.set(Step.Conflicts);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentStepIndex.update(x => x - 1);
|
||||
|
||||
// Reset when returning to the first step
|
||||
if (this.currentStepIndex() === Step.Import) {
|
||||
this.fileUploadControl.reset();
|
||||
(this.importSettingsForm.get('ageRatingConflictResolutions') as FormArray).clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
isFileSelected() {
|
||||
const files = this.uploadForm.get('files')?.value;
|
||||
return files && files.length === 1;
|
||||
}
|
||||
|
||||
protected readonly Step = Step;
|
||||
protected readonly WikiLink = WikiLink;
|
||||
protected readonly ImportModes = ImportModes;
|
||||
protected readonly ConflictResolutions = ConflictResolutions;
|
||||
protected readonly ConflictResolution = ConflictResolution;
|
||||
}
|
@ -3,7 +3,7 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
inject, input,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
@ -29,6 +29,8 @@ export class LibrarySelectorComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
@Input() member: Member | undefined;
|
||||
preSelectedLibraries = input<number[]>([]);
|
||||
|
||||
@Output() selected: EventEmitter<Array<Library>> = new EventEmitter<Array<Library>>();
|
||||
|
||||
allLibraries: Library[] = [];
|
||||
@ -61,6 +63,14 @@ export class LibrarySelectorComponent implements OnInit {
|
||||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
this.selected.emit(this.selections.selected());
|
||||
} else if (this.preSelectedLibraries().length > 0) {
|
||||
this.preSelectedLibraries().forEach((id) => {
|
||||
const foundLib = this.allLibraries.find(lib => lib.id === id);
|
||||
if (foundLib) {
|
||||
this.selections.toggle(foundLib, true, (a, b) => a.name === b.name);
|
||||
}
|
||||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
@ -0,0 +1,151 @@
|
||||
<ng-container *transloco="let t; prefix: 'manage-metadata-settings'" [formGroup]="settingsForm()">
|
||||
|
||||
<div class="row g-0 align-items-start mb-4">
|
||||
<div class="col">
|
||||
@if(settingsForm().get('blacklist'); as formControl) {
|
||||
<app-setting-item
|
||||
[title]="t('blacklist-label')"
|
||||
[subtitle]="t('blacklist-tooltip')">
|
||||
|
||||
<ng-template #view>
|
||||
@let val = breakTags(formControl.value);
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
|
||||
</ng-template>
|
||||
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm().get('whitelist'); as formControl) {
|
||||
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = breakTags(formControl.value);
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>s
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4 id="age-rating-header">{{t('age-rating-mapping-title')}}</h4>
|
||||
<p>{{t('age-rating-mapping-description')}}</p>
|
||||
|
||||
<div formArrayName="ageRatingMappings">
|
||||
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
|
||||
<div [formGroupName]="i" class="row mb-2">
|
||||
<div class="col-md-4 d-flex align-items-center justify-content-center">
|
||||
<input id="age-rating-{{i}}" type="text" class="form-control" formControlName="str" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center justify-content-center">
|
||||
<i class="fa fa-arrow-right" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center justify-content-center">
|
||||
<select class="form-select" formControlName="rating">
|
||||
@for (ageRating of ageRatings(); track ageRating.value) {
|
||||
<option [ngValue]="ageRating.value">
|
||||
{{ageRating.value | ageRating}}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button [attr.aria-label]="'age-rating-' + i" class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-age-rating-mapping-label')}}</span>
|
||||
</button>
|
||||
|
||||
@if($last) {
|
||||
<button [attr.aria-label]="'age-rating-header'" class="btn btn-icon" (click)="addAgeRatingMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('add-age-rating-mapping-label')}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<button [attr.aria-label]="'age-rating-header'" class="btn btn-secondary" (click)="addAgeRatingMapping()">
|
||||
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t('add-age-rating-mapping-label')}}
|
||||
</button>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4 id="field-mapping-header">{{t('field-mapping-title')}}</h4>
|
||||
<p>{{t('field-mapping-description')}}</p>
|
||||
<div formArrayName="fieldMappings">
|
||||
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
|
||||
<div [formGroupName]="i" class="row mb-2">
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="sourceType">
|
||||
<option [ngValue]="MetadataFieldType.Genre">{{t('genre')}}</option>
|
||||
<option [ngValue]="MetadataFieldType.Tag">{{t('tag')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input id="field-mapping-{{i}}" type="text" class="form-control" formControlName="sourceValue"
|
||||
[placeholder]="t('source-genre-tags-placeholder')" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="destinationType">
|
||||
<option [ngValue]="MetadataFieldType.Genre">{{t('genre')}}</option>
|
||||
<option [ngValue]="MetadataFieldType.Tag">{{t('tag')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control" formControlName="destinationValue"
|
||||
[placeholder]="t('dest-genre-tags-placeholder')" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check">
|
||||
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
|
||||
formControlName="excludeFromSource">
|
||||
<label [for]="'remove-source-tag-' + i" class="form-check-label">
|
||||
{{t('remove-source-tag-label')}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button [attr.aria-label]="'field-mapping-' + i" class="btn btn-icon" (click)="removeFieldMappingRow(i)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('remove-field-mapping-label')}}</span>
|
||||
</button>
|
||||
|
||||
@if ($last) {
|
||||
<button [attr.aria-label]="'field-mapping-header'" class="btn btn-icon" (click)="addFieldMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{t('add-field-mapping-label')}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<button [attr.aria-label]="'field-mapping-header'" class="btn btn-secondary" (click)="addFieldMapping()">
|
||||
<i class="fa fa-plus me-1" aria-hidden="true"></i>{{t('add-field-mapping-label')}}
|
||||
</button>
|
||||
}
|
||||
|
||||
</div>
|
||||
</ng-container>
|
@ -0,0 +1,3 @@
|
||||
.text-muted {
|
||||
font-size: 0.875rem;
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, input, OnInit, signal} from '@angular/core';
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {FormBuilder, FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {MetadataFieldMapping, MetadataFieldType, MetadataSettings} from "../_models/metadata-settings";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {DownloadService} from "../../shared/_services/download.service";
|
||||
|
||||
export type MetadataMappingsExport = {
|
||||
ageRatingMappings: Record<string, AgeRating>,
|
||||
fieldMappings: Array<MetadataFieldMapping>,
|
||||
blacklist: Array<string>,
|
||||
whitelist: Array<string>,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-metadata-mappings',
|
||||
imports: [
|
||||
AgeRatingPipe,
|
||||
DefaultValuePipe,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SettingItemComponent,
|
||||
TagBadgeComponent,
|
||||
TranslocoDirective,
|
||||
],
|
||||
templateUrl: './manage-metadata-mappings.component.html',
|
||||
styleUrl: './manage-metadata-mappings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ManageMetadataMappingsComponent implements OnInit {
|
||||
|
||||
private readonly downloadService = inject(DownloadService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
|
||||
/**
|
||||
* The FormGroup to use, this component will add its own controls
|
||||
*/
|
||||
settingsForm = input.required<FormGroup>();
|
||||
settings = input.required<MetadataSettings>()
|
||||
/**
|
||||
* If we should display the extended metadata processing toggle and export button
|
||||
*/
|
||||
showHeader = input(true);
|
||||
|
||||
ageRatings = signal<Array<AgeRatingDto>>([]);
|
||||
|
||||
ageRatingMappings = this.fb.array<FormGroup<{
|
||||
str: FormControl<string | null>,
|
||||
rating: FormControl<AgeRating | null>
|
||||
}>>([]);
|
||||
fieldMappings = this.fb.array<FormGroup<{
|
||||
id: FormControl<number | null>
|
||||
sourceType: FormControl<MetadataFieldType | null>,
|
||||
destinationType: FormControl<MetadataFieldType | null>,
|
||||
sourceValue: FormControl<string | null>,
|
||||
destinationValue: FormControl<string | null>,
|
||||
excludeFromSource: FormControl<boolean | null>,
|
||||
}>>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings.set(ratings);
|
||||
});
|
||||
|
||||
const settings = this.settings();
|
||||
const settingsForm = this.settingsForm();
|
||||
|
||||
settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
|
||||
settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
|
||||
settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
|
||||
settingsForm.addControl('fieldMappings', this.fieldMappings);
|
||||
|
||||
if (settings.ageRatingMappings) {
|
||||
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
|
||||
this.addAgeRatingMapping(str, rating);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.fieldMappings) {
|
||||
settings.fieldMappings.forEach(mapping => {
|
||||
this.addFieldMapping(mapping);
|
||||
});
|
||||
}
|
||||
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
breakTags(csString: string) {
|
||||
if (csString) {
|
||||
return csString.split(',');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public packData(): MetadataMappingsExport {
|
||||
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc: Record<string, AgeRating>, control) => {
|
||||
const { str, rating } = control.value;
|
||||
if (str && rating) {
|
||||
acc[str] = rating;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const fieldMappings = this.fieldMappings.controls
|
||||
.map((control) => control.value as MetadataFieldMapping)
|
||||
.filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0);
|
||||
|
||||
const blacklist = (this.settingsForm().get('blacklist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0);
|
||||
const whitelist = (this.settingsForm().get('whitelist')?.value || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0);
|
||||
|
||||
return {
|
||||
ageRatingMappings: ageRatingMappings,
|
||||
fieldMappings: fieldMappings,
|
||||
blacklist: blacklist,
|
||||
whitelist: whitelist,
|
||||
}
|
||||
}
|
||||
|
||||
export() {
|
||||
const data = this.packData();
|
||||
this.downloadService.downloadObjectAsJson(data, translate('manage-metadata-settings.export-file-name'))
|
||||
}
|
||||
|
||||
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
|
||||
const mappingGroup = this.fb.group({
|
||||
str: [str, Validators.required],
|
||||
rating: [rating, Validators.required]
|
||||
});
|
||||
|
||||
this.ageRatingMappings.push(mappingGroup);
|
||||
}
|
||||
|
||||
removeAgeRatingMappingRow(index: number) {
|
||||
this.ageRatingMappings.removeAt(index);
|
||||
}
|
||||
|
||||
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
|
||||
const mappingGroup = this.fb.group({
|
||||
id: [mapping?.id || 0],
|
||||
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
|
||||
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
|
||||
sourceValue: [mapping?.sourceValue || '', Validators.required],
|
||||
destinationValue: [mapping?.destinationValue || ''],
|
||||
excludeFromSource: [mapping?.excludeFromSource || false]
|
||||
});
|
||||
|
||||
this.fieldMappings.push(mappingGroup);
|
||||
}
|
||||
|
||||
removeFieldMappingRow(index: number) {
|
||||
this.fieldMappings.removeAt(index);
|
||||
}
|
||||
|
||||
protected readonly MetadataFieldType = MetadataFieldType;
|
||||
}
|
@ -1,22 +1,54 @@
|
||||
<ng-container *transloco="let t; read:'manage-metadata-settings'">
|
||||
|
||||
|
||||
<p>{{t('description')}}</p>
|
||||
@if (isLoaded) {
|
||||
<form [formGroup]="settingsForm">
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enabled'); as formControl) {
|
||||
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
<div class="col-md-6 col-sm-12">
|
||||
@if(settingsForm.get('enabled'); as formControl) {
|
||||
<app-setting-switch [title]="t('enabled-label')" [subtitle]="t('enabled-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enabled" type="checkbox" class="form-check-input" formControlName="enabled">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
@if (settingsForm.get('enableExtendedMetadataProcessing'); as control) {
|
||||
<app-setting-switch [title]="t('enable-extended-metadata-processing-label')" [subtitle]="t('enable-extended-metadata-processing-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-extended-metadata-processing" type="checkbox" class="form-check-input" formControlName="enableExtendedMetadataProcessing">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-secondary" (click)="manageMetadataMappingsComponent.export()">
|
||||
{{t('export-settings')}}
|
||||
</button>
|
||||
<div class="text-muted mt-2">{{t('export-tooltip')}}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-secondary" routerLink="/settings" [fragment]="SettingsTabId.MappingsImport">
|
||||
{{t('import-settings')}}
|
||||
</button>
|
||||
<div class="text-muted mt-2">{{t('import-tooltip')}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4>{{t('series-header')}}</h4>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enableSummary'); as formControl) {
|
||||
<app-setting-switch [title]="t('summary-label')" [subtitle]="t('summary-tooltip')">
|
||||
@ -91,8 +123,7 @@
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<!-- Chapter-based fields -->
|
||||
<h5>{{t('chapter-header')}}</h5>
|
||||
<h4>{{t('chapter-header')}}</h4>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('enableChapterTitle'); as formControl) {
|
||||
<app-setting-switch [title]="t('enable-chapter-title-label')" [subtitle]="t('enable-chapter-title-tooltip')">
|
||||
@ -155,6 +186,7 @@
|
||||
|
||||
@if(settingsForm.get('enablePeople'); as formControl) {
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('people-header')}}</h4>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
|
||||
@ -195,13 +227,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<h4>{{t('tags-header')}}</h4>
|
||||
<div class="row mt-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
@if(settingsForm.get('enableGenres'); as formControl) {
|
||||
<app-setting-switch [title]="t('enable-genres-label')" [subtitle]="t('enable-genres-tooltip')">
|
||||
@ -226,144 +255,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('blacklist'); as formControl) {
|
||||
<app-setting-item [title]="t('blacklist-label')" [subtitle]="t('blacklist-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = breakTags(formControl.value);
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>s
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="blacklist" class="form-control" formControlName="blacklist"></textarea>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('whitelist'); as formControl) {
|
||||
<app-setting-item [title]="t('whitelist-label')" [subtitle]="t('whitelist-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = breakTags(formControl.value);
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>s
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="whitelist" class="form-control" formControlName="whitelist"></textarea>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4>{{t('age-rating-mapping-title')}}</h4>
|
||||
<p>{{t('age-rating-mapping-description')}}</p>
|
||||
|
||||
<div formArrayName="ageRatingMappings">
|
||||
@for(mapping of ageRatingMappings.controls; track mapping; let i = $index) {
|
||||
<div [formGroupName]="i" class="row mb-2">
|
||||
<div class="col-md-4 d-flex align-items-center justify-content-center">
|
||||
<input type="text" class="form-control" formControlName="str" autocomplete="off" />
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center justify-content-center">
|
||||
<i class="fa fa-arrow-right" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-center justify-content-center">
|
||||
<select class="form-select" formControlName="rating">
|
||||
@for (ageRating of ageRatings; track ageRating.value) {
|
||||
<option [value]="ageRating.value">
|
||||
{{ageRating.value | ageRating}}
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-icon" (click)="removeAgeRatingMappingRow(i)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@if($last) {
|
||||
<button class="btn btn-icon" (click)="addAgeRatingMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<button class="btn btn-secondary" (click)="addAgeRatingMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-age-rating-mapping-label')}}
|
||||
</button>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<!-- Field Mapping Table -->
|
||||
<h4>{{t('field-mapping-title')}}</h4>
|
||||
<p>{{t('field-mapping-description')}}</p>
|
||||
<div formArrayName="fieldMappings">
|
||||
@for (mapping of fieldMappings.controls; track mapping; let i = $index) {
|
||||
<div [formGroupName]="i" class="row mb-2">
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="sourceType">
|
||||
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
|
||||
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control" formControlName="sourceValue"
|
||||
placeholder="Source genre/tag" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select class="form-select" formControlName="destinationType">
|
||||
<option [value]="MetadataFieldType.Genre">{{t('genre')}}</option>
|
||||
<option [value]="MetadataFieldType.Tag">{{t('tag')}}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input type="text" class="form-control" formControlName="destinationValue"
|
||||
placeholder="Destination genre/tag" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="form-check">
|
||||
<input id="remove-source-tag-{{i}}" type="checkbox" class="form-check-input"
|
||||
formControlName="excludeFromSource">
|
||||
<label [for]="'remove-source-tag-' + i" class="form-check-label">
|
||||
{{t('remove-source-tag-label')}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-icon" (click)="removeFieldMappingRow(i)">
|
||||
<i class="fa fa-trash-alt" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@if ($last) {
|
||||
<button class="btn btn-icon" (click)="addFieldMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<button class="btn btn-secondary" (click)="addFieldMapping()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i> {{t('add-field-mapping-label')}}
|
||||
</button>
|
||||
}
|
||||
|
||||
</div>
|
||||
@if (settings) {
|
||||
<app-manage-metadata-mappings [settings]="settings" [settingsForm]="settingsForm" />
|
||||
}
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
|
@ -1,23 +1,31 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {debounceTime, switchMap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {filter, map} from "rxjs/operators";
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {MetadataFieldMapping, MetadataFieldType} from "../_models/metadata-settings";
|
||||
import {map} from "rxjs/operators";
|
||||
import {MetadataSettings} from "../_models/metadata-settings";
|
||||
import {PersonRole} from "../../_models/metadata/person";
|
||||
import {PersonRolePipe} from "../../_pipes/person-role.pipe";
|
||||
import {allMetadataSettingField, MetadataSettingField} from "../_models/metadata-setting-field";
|
||||
import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe";
|
||||
import {
|
||||
ManageMetadataMappingsComponent,
|
||||
MetadataMappingsExport
|
||||
} from "../manage-metadata-mappings/manage-metadata-mappings.component";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
|
||||
|
||||
|
||||
@Component({
|
||||
@ -26,12 +34,10 @@ import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SettingSwitchComponent,
|
||||
SettingItemComponent,
|
||||
DefaultValuePipe,
|
||||
TagBadgeComponent,
|
||||
AgeRatingPipe,
|
||||
PersonRolePipe,
|
||||
MetadataSettingFiledPipe,
|
||||
ManageMetadataMappingsComponent,
|
||||
RouterLink,
|
||||
|
||||
],
|
||||
templateUrl: './manage-metadata-settings.component.html',
|
||||
@ -40,34 +46,26 @@ import {MetadataSettingFiledPipe} from "../../_pipes/metadata-setting-filed.pipe
|
||||
})
|
||||
export class ManageMetadataSettingsComponent implements OnInit {
|
||||
|
||||
protected readonly MetadataFieldType = MetadataFieldType;
|
||||
@ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent;
|
||||
|
||||
private readonly settingService = inject(SettingsService);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
ageRatings: Array<AgeRatingDto> = [];
|
||||
ageRatingMappings = this.fb.array([]);
|
||||
fieldMappings = this.fb.array([]);
|
||||
settings: MetadataSettings | undefined = undefined;
|
||||
personRoles: PersonRole[] = [PersonRole.Writer, PersonRole.CoverArtist, PersonRole.Character];
|
||||
isLoaded = false;
|
||||
allMetadataSettingFields = allMetadataSettingField;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings = ratings;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
||||
this.settingsForm.addControl('ageRatingMappings', this.ageRatingMappings);
|
||||
this.settingsForm.addControl('fieldMappings', this.fieldMappings);
|
||||
|
||||
this.settingService.getMetadataSettings().subscribe(settings => {
|
||||
this.settings = settings;
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.settingsForm.addControl('enabled', new FormControl(settings.enabled, []));
|
||||
this.settingsForm.addControl('enableExtendedMetadataProcessing', new FormControl(settings.enableExtendedMetadataProcessing, []));
|
||||
this.settingsForm.addControl('enableSummary', new FormControl(settings.enableSummary, []));
|
||||
this.settingsForm.addControl('enableLocalizedName', new FormControl(settings.enableLocalizedName, []));
|
||||
this.settingsForm.addControl('enablePublicationStatus', new FormControl(settings.enablePublicationStatus, []));
|
||||
@ -86,8 +84,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
|
||||
this.settingsForm.addControl('enableChapterPublisher', new FormControl(settings.enableChapterPublisher, []));
|
||||
this.settingsForm.addControl('enableChapterCoverImage', new FormControl(settings.enableChapterCoverImage, []));
|
||||
|
||||
this.settingsForm.addControl('blacklist', new FormControl((settings.blacklist || '').join(','), []));
|
||||
this.settingsForm.addControl('whitelist', new FormControl((settings.whitelist || '').join(','), []));
|
||||
this.settingsForm.addControl('firstLastPeopleNaming', new FormControl((settings.firstLastPeopleNaming), []));
|
||||
this.settingsForm.addControl('personRoles', this.fb.group(
|
||||
Object.fromEntries(
|
||||
@ -107,19 +103,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
|
||||
)
|
||||
));
|
||||
|
||||
|
||||
if (settings.ageRatingMappings) {
|
||||
Object.entries(settings.ageRatingMappings).forEach(([str, rating]) => {
|
||||
this.addAgeRatingMapping(str, rating);
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.fieldMappings) {
|
||||
settings.fieldMappings.forEach(mapping => {
|
||||
this.addFieldMapping(mapping);
|
||||
});
|
||||
}
|
||||
|
||||
this.settingsForm.get('enablePeople')?.valueChanges.subscribe(enabled => {
|
||||
const firstLastControl = this.settingsForm.get('firstLastPeopleNaming');
|
||||
if (enabled) {
|
||||
@ -156,49 +139,17 @@ export class ManageMetadataSettingsComponent implements OnInit {
|
||||
|
||||
}
|
||||
|
||||
breakTags(csString: string) {
|
||||
if (csString) {
|
||||
return csString.split(',');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
packData(withFieldMappings: boolean = true) {
|
||||
const model = this.settingsForm.value;
|
||||
|
||||
// Convert FormArray to dictionary
|
||||
const ageRatingMappings = this.ageRatingMappings.controls.reduce((acc, control) => {
|
||||
// @ts-ignore
|
||||
const { str, rating } = control.value;
|
||||
if (str && rating) {
|
||||
// @ts-ignore
|
||||
acc[str] = parseInt(rating + '', 10) as AgeRating;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
const exp: MetadataMappingsExport = this.manageMetadataMappingsComponent.packData()
|
||||
|
||||
const fieldMappings = this.fieldMappings.controls.map((control) => {
|
||||
const value = control.value as MetadataFieldMapping;
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
sourceType: parseInt(value.sourceType + '', 10),
|
||||
destinationType: parseInt(value.destinationType + '', 10),
|
||||
sourceValue: value.sourceValue,
|
||||
destinationValue: value.destinationValue,
|
||||
excludeFromSource: value.excludeFromSource
|
||||
}
|
||||
}).filter(m => m.sourceValue.length > 0 && m.destinationValue.length > 0);
|
||||
|
||||
// Translate blacklist string -> Array<string>
|
||||
return {
|
||||
...model,
|
||||
ageRatingMappings,
|
||||
fieldMappings: withFieldMappings ? fieldMappings : [],
|
||||
blacklist: (model.blacklist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0),
|
||||
whitelist: (model.whitelist || '').split(',').map((item: string) => item.trim()).filter((tag: string) => tag.length > 0),
|
||||
ageRatingMappings: exp.ageRatingMappings,
|
||||
fieldMappings: withFieldMappings ? exp.fieldMappings : [],
|
||||
blacklist: exp.blacklist,
|
||||
whitelist: exp.whitelist,
|
||||
personRoles: Object.entries(this.settingsForm.get('personRoles')!.value)
|
||||
.filter(([_, value]) => value)
|
||||
.map(([key, _]) => this.personRoles[parseInt(key.split('_')[1], 10)]),
|
||||
@ -208,36 +159,6 @@ export class ManageMetadataSettingsComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
addAgeRatingMapping(str: string = '', rating: AgeRating = AgeRating.Unknown) {
|
||||
const mappingGroup = this.fb.group({
|
||||
str: [str, Validators.required],
|
||||
rating: [rating, Validators.required]
|
||||
});
|
||||
// @ts-ignore
|
||||
this.ageRatingMappings.push(mappingGroup);
|
||||
}
|
||||
|
||||
removeAgeRatingMappingRow(index: number) {
|
||||
this.ageRatingMappings.removeAt(index);
|
||||
}
|
||||
|
||||
addFieldMapping(mapping: MetadataFieldMapping | null = null) {
|
||||
const mappingGroup = this.fb.group({
|
||||
id: [mapping?.id || 0],
|
||||
sourceType: [mapping?.sourceType || MetadataFieldType.Genre, Validators.required],
|
||||
destinationType: [mapping?.destinationType || MetadataFieldType.Genre, Validators.required],
|
||||
sourceValue: [mapping?.sourceValue || '', Validators.required],
|
||||
destinationValue: [mapping?.destinationValue || ''],
|
||||
excludeFromSource: [mapping?.excludeFromSource || false]
|
||||
});
|
||||
|
||||
//@ts-ignore
|
||||
this.fieldMappings.push(mappingGroup);
|
||||
}
|
||||
|
||||
removeFieldMappingRow(index: number) {
|
||||
this.fieldMappings.removeAt(index);
|
||||
}
|
||||
|
||||
|
||||
protected readonly SettingsTabId = SettingsTabId;
|
||||
}
|
||||
|
@ -0,0 +1,282 @@
|
||||
<ng-container *transloco="let t; prefix:'manage-oidc-connect'">
|
||||
|
||||
<div class="position-relative">
|
||||
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save(true)">{{t('save')}}</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="settingsForm">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
|
||||
</div>
|
||||
|
||||
<h4>{{t('provider-title')}}</h4>
|
||||
<div class="text-muted" [innerHtml]="t('provider-tooltip') | safeHtml"></div>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('authority'); as formControl) {
|
||||
<app-setting-item [title]="t('authority-label')" [subtitle]="t('authority-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-authority" class="form-control"
|
||||
formControlName="authority" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="invalid-uri-validation" class="invalid-feedback">
|
||||
@if (formControl.errors?.invalidUri) {
|
||||
<div>{{t('invalid-uri')}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('clientId'); as formControl) {
|
||||
<app-setting-item [title]="t('client-id-label')" [subtitle]="t('client-id-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-client-id" aria-describedby="oidc-client-id-validations" class="form-control"
|
||||
formControlName="clientId" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="oidc-client-id-validations" class="invalid-feedback">
|
||||
@if (formControl.errors && formControl.errors.requiredIf) {
|
||||
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('secret'); as formControl) {
|
||||
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
|
||||
<ng-template #view>
|
||||
@if (formControl.value) {
|
||||
{{'*'.repeat(10)}}
|
||||
} @else {
|
||||
{{ null | defaultValue }}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
|
||||
formControlName="secret" type="password"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="oidc-secret-validations" class="invalid-feedback">
|
||||
@if (formControl.errors && formControl.errors.requiredIf) {
|
||||
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('behavior-title')}}</h4>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('providerName'); as formControl) {
|
||||
<app-setting-item [title]="t('provider-name-label')" [subtitle]="t('provider-name-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-provider-name" aria-describedby="oidc-provider-name-validations" class="form-control"
|
||||
formControlName="providerName" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('provisionAccounts'); as formControl) {
|
||||
<app-setting-switch [title]="t('provision-accounts-label')" [subtitle]="t('provision-accounts-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="provision-accounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
|
||||
<app-setting-switch [title]="t('require-verified-email-label')" [subtitle]="t('require-verified-email-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="require-verified-email" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('syncUserSettings'); as formControl) {
|
||||
<app-setting-switch [title]="t('sync-user-settings-label')" [subtitle]="t('sync-user-settings-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="sync-user-settings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('autoLogin'); as formControl) {
|
||||
<app-setting-switch [title]="t('auto-login-label')" [subtitle]="t('auto-login-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="auto-login" type="checkbox" class="form-check-input" formControlName="autoLogin">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('disablePasswordAuthentication'); as formControl) {
|
||||
<app-setting-switch [title]="t('disable-password-authentication-label')" [subtitle]="t('disable-password-authentication-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="disable-password-authentication" type="checkbox" class="form-check-input" formControlName="disablePasswordAuthentication">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('defaults-title')}}</h4>
|
||||
<div class="text-muted">{{t('defaults-requirement')}}</div>
|
||||
<ng-container>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('defaultAgeRestriction'); as formControl) {
|
||||
<app-setting-item [title]="t('default-age-restriction-label')" [subtitle]="t('default-age-restriction-tooltip')">
|
||||
<ng-template #view>
|
||||
<div>{{formControl.value | ageRating}}</div>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" formControlName="defaultAgeRestriction">
|
||||
<option value="-1">{{t('no-restriction')}}</option>
|
||||
@for (ageRating of ageRatings(); track ageRating.value) {
|
||||
<option [value]="ageRating.value">{{ageRating.title}}</option>
|
||||
}
|
||||
</select>
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('defaultIncludeUnknowns'); as formControl) {
|
||||
<app-setting-switch [title]="t('default-include-unknowns-label')" [subtitle]="t('default-include-unknowns-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="default-include-unknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (oidcSettings()) {
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-role-selector (selected)="updateRoles($event)" [allowAdmin]="true" [preSelectedRoles]="selectedRoles()"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibraries($event)" [preSelectedLibraries]="selectedLibraries()"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('advanced-title')}}</h4>
|
||||
<div class="text-muted">{{t('advanced-tooltip')}}</div>
|
||||
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('rolesPrefix'); as formControl) {
|
||||
<app-setting-item [title]="t('roles-prefix-label')" [subtitle]="t('roles-prefix-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oidc-roles-prefix" class="form-control"
|
||||
formControlName="rolesPrefix" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('rolesClaim'); as formControl) {
|
||||
<app-setting-item [title]="t('roles-claim-label')" [subtitle]="t('roles-claim-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oidc-roles-claim" class="form-control"
|
||||
formControlName="rolesClaim" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('customScopes'); as formControl) {
|
||||
<app-setting-item [title]="t('custom-scopes-label')" [subtitle]="t('custom-scopes-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = breakString(formControl.value);
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="custom-scopes" class="form-control" formControlName="customScopes"></textarea>
|
||||
</ng-template>
|
||||
|
||||
</app-setting-item>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</form>
|
||||
|
||||
</ng-container>
|
@ -0,0 +1,8 @@
|
||||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 5px;
|
||||
top: -42px;
|
||||
}
|
@ -0,0 +1,206 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ServerSettings} from "../_models/server-settings";
|
||||
import {
|
||||
AbstractControl,
|
||||
AsyncValidatorFn,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn
|
||||
} from "@angular/forms";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {OidcConfig} from "../_models/oidc-config";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {RestrictionSelectorComponent} from "../../user-settings/restriction-selector/restriction-selector.component";
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {allRoles, Role} from "../../_services/account.service";
|
||||
import {Library} from "../../_models/library/library";
|
||||
import {LibraryService} from "../../_services/library.service";
|
||||
import {LibrarySelectorComponent} from "../library-selector/library-selector.component";
|
||||
import {RoleSelectorComponent} from "../role-selector/role-selector.component";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-open-idconnect',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SettingItemComponent,
|
||||
SettingSwitchComponent,
|
||||
AgeRatingPipe,
|
||||
LibrarySelectorComponent,
|
||||
RoleSelectorComponent,
|
||||
SafeHtmlPipe,
|
||||
DefaultValuePipe,
|
||||
TagBadgeComponent
|
||||
],
|
||||
templateUrl: './manage-open-idconnect.component.html',
|
||||
styleUrl: './manage-open-idconnect.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ManageOpenIDConnectComponent implements OnInit {
|
||||
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
oidcSettings = signal<OidcConfig | undefined>(undefined);
|
||||
ageRatings = signal<AgeRatingDto[]>([]);
|
||||
selectedLibraries = signal<number[]>([]);
|
||||
selectedRoles = signal<string[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings.set(ratings);
|
||||
});
|
||||
|
||||
this.settingsService.getServerSettings().subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings.set(this.serverSettings.oidcConfig);
|
||||
this.selectedRoles.set(this.serverSettings.oidcConfig.defaultRoles);
|
||||
this.selectedLibraries.set(this.serverSettings.oidcConfig.defaultLibraries);
|
||||
|
||||
this.settingsForm.addControl('authority', new FormControl(this.serverSettings.oidcConfig.authority, [], [this.authorityValidator()]));
|
||||
this.settingsForm.addControl('clientId', new FormControl(this.serverSettings.oidcConfig.clientId, [this.requiredIf('authority')]));
|
||||
this.settingsForm.addControl('secret', new FormControl(this.serverSettings.oidcConfig.secret, [this.requiredIf('authority')]));
|
||||
this.settingsForm.addControl('provisionAccounts', new FormControl(this.serverSettings.oidcConfig.provisionAccounts, []));
|
||||
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.serverSettings.oidcConfig.requireVerifiedEmail, []));
|
||||
this.settingsForm.addControl('syncUserSettings', new FormControl(this.serverSettings.oidcConfig.syncUserSettings, []));
|
||||
this.settingsForm.addControl('rolesPrefix', new FormControl(this.serverSettings.oidcConfig.rolesPrefix, []));
|
||||
this.settingsForm.addControl('rolesClaim', new FormControl(this.serverSettings.oidcConfig.rolesClaim, []));
|
||||
this.settingsForm.addControl('autoLogin', new FormControl(this.serverSettings.oidcConfig.autoLogin, []));
|
||||
this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.serverSettings.oidcConfig.disablePasswordAuthentication, []));
|
||||
this.settingsForm.addControl('providerName', new FormControl(this.serverSettings.oidcConfig.providerName, []));
|
||||
this.settingsForm.addControl("defaultAgeRestriction", new FormControl(this.serverSettings.oidcConfig.defaultAgeRestriction, []));
|
||||
this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, []));
|
||||
this.settingsForm.addControl('customScopes', new FormControl(this.serverSettings.oidcConfig.customScopes.join(","), []))
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
filter(() => {
|
||||
// Do not auto save when provider settings have changed
|
||||
const settings: OidcConfig = this.settingsForm.getRawValue();
|
||||
return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId;
|
||||
}),
|
||||
tap(() => this.save())
|
||||
).subscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateRoles(roles: string[]) {
|
||||
this.selectedRoles.set(roles);
|
||||
this.save();
|
||||
}
|
||||
|
||||
updateLibraries(libraries: Library[]) {
|
||||
this.selectedLibraries.set(libraries.map(l => l.id));
|
||||
this.save();
|
||||
}
|
||||
|
||||
save(showConfirmation: boolean = false) {
|
||||
if (!this.settingsForm.valid || !this.serverSettings || !this.oidcSettings) return;
|
||||
|
||||
const data = this.settingsForm.getRawValue();
|
||||
const newSettings = Object.assign({}, this.serverSettings);
|
||||
newSettings.oidcConfig = data as OidcConfig;
|
||||
newSettings.oidcConfig.defaultAgeRestriction = parseInt(newSettings.oidcConfig.defaultAgeRestriction + '', 10) as AgeRating;
|
||||
newSettings.oidcConfig.defaultRoles = this.selectedRoles();
|
||||
newSettings.oidcConfig.defaultLibraries = this.selectedLibraries();
|
||||
newSettings.oidcConfig.customScopes = (data.customScopes as string)
|
||||
.split(',').map((item: string) => item.trim())
|
||||
.filter((scope: string) => scope.length > 0);
|
||||
|
||||
this.settingsService.updateServerSettings(newSettings).subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings.set(data.oidcConfig);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (showConfirmation) {
|
||||
this.toastr.success(translate('manage-oidc-connect.save-success'))
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error(error);
|
||||
this.toastr.error(translate('errors.generic'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
breakString(s: string) {
|
||||
if (s) {
|
||||
return s.split(',');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
authorityValidator(): AsyncValidatorFn {
|
||||
return (control: AbstractControl) => {
|
||||
let uri: string = control.value;
|
||||
if (!uri || uri.trim().length === 0) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(uri);
|
||||
} catch {
|
||||
return of({'invalidUri': {'uri': uri}} as ValidationErrors)
|
||||
}
|
||||
|
||||
return this.settingsService.ifValidAuthority(uri).pipe(map(ok => {
|
||||
if (ok) return null;
|
||||
|
||||
return {'invalidUri': {'uri': uri}} as ValidationErrors;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
requiredIf(other: string): ValidatorFn {
|
||||
return (control): ValidationErrors | null => {
|
||||
const otherControl = this.settingsForm.get(other);
|
||||
if (!otherControl) return null;
|
||||
|
||||
if (otherControl.invalid) return null;
|
||||
|
||||
const v = otherControl.value;
|
||||
if (!v || v.length === 0) return null;
|
||||
|
||||
const own = control.value;
|
||||
if (own && own.length > 0) return null;
|
||||
|
||||
return {'requiredIf': {'other': other, 'otherValue': v}}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
<ng-container *transloco="let t; prefix: 'manage-metadata-settings'">
|
||||
|
||||
@if (licenseService.hasValidLicenseSignal()) {
|
||||
<p class="alert alert-warning" role="alert">{{t('k+-warning')}}</p>
|
||||
}
|
||||
|
||||
<form class="row g-0 mt-4 mb-4" [formGroup]="settingsForm">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
@if (settingsForm.get('enableExtendedMetadataProcessing'); as control) {
|
||||
<app-setting-switch [title]="t('enable-extended-metadata-processing-label')" [subtitle]="t('enable-extended-metadata-processing-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="enable-extended-metadata-processing" type="checkbox" class="form-check-input" formControlName="enableExtendedMetadataProcessing">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-secondary" (click)="manageMetadataMappingsComponent.export()">
|
||||
{{ t('export-settings') }}
|
||||
</button>
|
||||
<div class="text-muted mt-2">{{t('export-tooltip')}}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<button class="btn btn-secondary" routerLink="/settings" [fragment]="SettingsTabId.MappingsImport">
|
||||
{{ t('import-settings') }}
|
||||
</button>
|
||||
<div class="text-muted mt-2">{{t('import-tooltip')}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
|
||||
<h4 class="mb-4">{{t('tags-header')}}</h4>
|
||||
|
||||
@if (settings) {
|
||||
<app-manage-metadata-mappings [settingsForm]="settingsForm" [settings]="settings"></app-manage-metadata-mappings>
|
||||
}
|
||||
</ng-container>
|
@ -0,0 +1,86 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
inject,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
|
||||
import {
|
||||
ManageMetadataMappingsComponent,
|
||||
MetadataMappingsExport
|
||||
} from "../manage-metadata-mappings/manage-metadata-mappings.component";
|
||||
import {MetadataSettings} from "../_models/metadata-settings";
|
||||
import {debounceTime, switchMap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {map} from "rxjs/operators";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {LicenseService} from "../../_services/license.service";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {SettingsTabId} from "../../sidenav/preference-nav/preference-nav.component";
|
||||
|
||||
/**
|
||||
* Metadata settings for which a K+ license is not required
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-manage-public-metadata-settings',
|
||||
imports: [
|
||||
ManageMetadataMappingsComponent,
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
SettingSwitchComponent,
|
||||
],
|
||||
templateUrl: './manage-public-metadata-settings.component.html',
|
||||
styleUrl: './manage-public-metadata-settings.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ManagePublicMetadataSettingsComponent implements OnInit {
|
||||
|
||||
@ViewChild(ManageMetadataMappingsComponent) manageMetadataMappingsComponent!: ManageMetadataMappingsComponent;
|
||||
|
||||
private readonly settingService = inject(SettingsService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly licenseService = inject(LicenseService);
|
||||
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
settings: MetadataSettings | undefined = undefined;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingService.getMetadataSettings().subscribe(settings => {
|
||||
this.settings = settings;
|
||||
|
||||
this.settingsForm.addControl('enableExtendedMetadataProcessing', new FormControl(this.settings.enableExtendedMetadataProcessing, []));
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(_ => this.packData()),
|
||||
switchMap((data) => this.settingService.updateMetadataSettings(data)),
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
packData() {
|
||||
const model = Object.assign({}, this.settings);
|
||||
const formValue = this.settingsForm.value;
|
||||
|
||||
const exp: MetadataMappingsExport = this.manageMetadataMappingsComponent.packData()
|
||||
|
||||
model.enableExtendedMetadataProcessing = formValue.enableExtendedMetadataProcessing;
|
||||
model.ageRatingMappings = exp.ageRatingMappings;
|
||||
model.fieldMappings = exp.fieldMappings;
|
||||
model.whitelist = exp.whitelist;
|
||||
model.blacklist = exp.blacklist;
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
protected readonly SettingsTabId = SettingsTabId;
|
||||
}
|
@ -145,6 +145,7 @@ export class ManageSettingsComponent implements OnInit {
|
||||
modelSettings.smtpConfig = this.serverSettings.smtpConfig;
|
||||
modelSettings.installId = this.serverSettings.installId;
|
||||
modelSettings.installVersion = this.serverSettings.installVersion;
|
||||
modelSettings.oidcConfig = this.serverSettings.oidcConfig;
|
||||
|
||||
// Disabled FormControls are not added to the value
|
||||
if (this.isDocker) {
|
||||
|
@ -10,6 +10,7 @@
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"></th>
|
||||
<th scope="col">{{t('name-header')}}</th>
|
||||
<th scope="col">{{t('last-active-header')}}</th>
|
||||
<th scope="col">{{t('sharing-header')}}</th>
|
||||
@ -20,6 +21,18 @@
|
||||
<tbody>
|
||||
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="d-flex flex-row justify-content-center align-items-center">
|
||||
@switch (member.identityProvider) {
|
||||
@case (IdentityProvider.OpenIdConnect) {
|
||||
<app-image imageUrl="assets/icons/open-id-connect-logo.svg" height="16px" width="16px"></app-image>
|
||||
}
|
||||
@case (IdentityProvider.Kavita) {
|
||||
<app-image imageUrl="assets/icons/favicon-16x16.png" height="16px" width="16px"></app-image>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td id="username--{{idx}}">
|
||||
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
|
||||
@if (member.isPending) {
|
||||
|
@ -12,8 +12,8 @@ import {InviteUserComponent} from '../invite-user/invite-user.component';
|
||||
import {EditUserComponent} from '../edit-user/edit-user.component';
|
||||
import {Router} from '@angular/router';
|
||||
import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component';
|
||||
import {AsyncPipe, NgClass, TitleCasePipe} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common';
|
||||
import {size, TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
@ -23,6 +23,10 @@ import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
|
||||
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {ServerSettings} from "../_models/server-settings";
|
||||
import {IdentityProvider} from "../../_models/user";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
@ -31,7 +35,7 @@ import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
|
||||
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
|
||||
RoleLocalizedPipe]
|
||||
RoleLocalizedPipe, ImageComponent]
|
||||
})
|
||||
export class ManageUsersComponent implements OnInit {
|
||||
|
||||
@ -41,6 +45,7 @@ export class ManageUsersComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly memberService = inject(MemberService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
@ -48,6 +53,7 @@ export class ManageUsersComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
members: Member[] = [];
|
||||
settings: ServerSettings | undefined = undefined;
|
||||
loggedInUsername = '';
|
||||
loadingMembers = false;
|
||||
libraryCount: number = 0;
|
||||
@ -64,6 +70,10 @@ export class ManageUsersComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMembers();
|
||||
|
||||
this.settingsService.getServerSettings().subscribe(settings => {
|
||||
this.settings = settings;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -97,8 +107,11 @@ export class ManageUsersComponent implements OnInit {
|
||||
}
|
||||
|
||||
openEditUser(member: Member) {
|
||||
if (!this.settings) return;
|
||||
|
||||
const modalRef = this.modalService.open(EditUserComponent, DefaultModalOptions);
|
||||
modalRef.componentInstance.member = member;
|
||||
modalRef.componentInstance.member.set(member);
|
||||
modalRef.componentInstance.settings.set(this.settings);
|
||||
modalRef.closed.subscribe(() => {
|
||||
this.loadMembers();
|
||||
});
|
||||
@ -154,4 +167,6 @@ export class ManageUsersComponent implements OnInit {
|
||||
getRoles(member: Member) {
|
||||
return member.roles.filter(item => item != 'Pleb');
|
||||
}
|
||||
|
||||
protected readonly IdentityProvider = IdentityProvider;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
inject, input,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
@ -33,6 +33,7 @@ export class RoleSelectorComponent implements OnInit {
|
||||
* This must have roles
|
||||
*/
|
||||
@Input() member: Member | undefined | User;
|
||||
preSelectedRoles = input<string[]>([]);
|
||||
/**
|
||||
* Allows the selection of Admin role
|
||||
*/
|
||||
@ -77,6 +78,13 @@ export class RoleSelectorComponent implements OnInit {
|
||||
foundRole[0].selected = true;
|
||||
}
|
||||
});
|
||||
} else if (this.preSelectedRoles().length > 0) {
|
||||
this.preSelectedRoles().forEach((role) => {
|
||||
const foundRole = this.selectedRoles.filter(item => item.data === role);
|
||||
if (foundRole.length > 0) {
|
||||
foundRole[0].selected = true;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// For new users, preselect LoginRole
|
||||
this.selectedRoles.forEach(role => {
|
||||
|
@ -1,10 +1,13 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {map, of} from 'rxjs';
|
||||
import {computed, Injectable, signal} from '@angular/core';
|
||||
import {map, of, tap} from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { ServerSettings } from './_models/server-settings';
|
||||
import {MetadataSettings} from "./_models/metadata-settings";
|
||||
import {MetadataMappingsExport} from "./manage-metadata-mappings/manage-metadata-mappings.component";
|
||||
import {FieldMappingsImportResult, ImportSettings} from "../_models/import-field-mappings";
|
||||
import {OidcPublicConfig} from "./_models/oidc-config";
|
||||
|
||||
/**
|
||||
* Used only for the Test Email Service call
|
||||
@ -28,6 +31,10 @@ export class SettingsService {
|
||||
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
|
||||
}
|
||||
|
||||
getPublicOidcConfig() {
|
||||
return this.http.get<OidcPublicConfig>(this.baseUrl + "settings/oidc");
|
||||
}
|
||||
|
||||
getMetadataSettings() {
|
||||
return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings');
|
||||
}
|
||||
@ -35,6 +42,14 @@ export class SettingsService {
|
||||
return this.http.post<MetadataSettings>(this.baseUrl + 'settings/metadata-settings', model);
|
||||
}
|
||||
|
||||
importFieldMappings(data: MetadataMappingsExport, settings: ImportSettings) {
|
||||
const body = {
|
||||
data: data,
|
||||
settings: settings,
|
||||
}
|
||||
return this.http.post<FieldMappingsImportResult>(this.baseUrl + 'settings/import-field-mappings', body);
|
||||
}
|
||||
|
||||
updateServerSettings(model: ServerSettings) {
|
||||
return this.http.post<ServerSettings>(this.baseUrl + 'settings', model);
|
||||
}
|
||||
@ -78,6 +93,11 @@ export class SettingsService {
|
||||
isValidCronExpression(val: string) {
|
||||
if (val === '' || val === undefined || val === null) return of(false);
|
||||
return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true'));
|
||||
}
|
||||
|
||||
ifValidAuthority(authority: string) {
|
||||
if (authority === '' || authority === undefined || authority === null) return of(false);
|
||||
|
||||
return this.http.post<boolean>(this.baseUrl + 'settings/is-valid-authority', {authority}, TextResonse).pipe(map(r => r + '' == 'true'));
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
DestroyRef, effect,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit
|
||||
@ -97,7 +97,6 @@ export class AppComponent implements OnInit {
|
||||
}), takeUntilDestroyed(this.destroyRef));
|
||||
|
||||
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
|
||||
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
@ -118,9 +117,7 @@ export class AppComponent implements OnInit {
|
||||
|
||||
|
||||
setCurrentUser() {
|
||||
const user = this.accountService.getUserFromLocalStorage();
|
||||
this.accountService.setCurrentUser(user);
|
||||
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (!user) return;
|
||||
|
||||
// Bootstrap anything that's needed
|
||||
|
@ -76,7 +76,7 @@ export class ImportCblComponent {
|
||||
|
||||
|
||||
fileUploadControl = new FormControl<undefined | Array<File>>(undefined, [
|
||||
FileUploadValidators.accept(['.cbl']),
|
||||
FileUploadValidators.accept(['.cbl'])
|
||||
]);
|
||||
|
||||
uploadForm = new FormGroup({
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user