Merge branch 'develop' into bugfix/side-nav-issue

This commit is contained in:
Robbie Davis 2025-08-03 12:34:30 -04:00
commit 47216a05c4
113 changed files with 14139 additions and 705 deletions

View 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);
}
}

View 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();
}
}

View File

@ -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

View 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();
}
}

View File

@ -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
},
],
};
}
}

View File

@ -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>

View 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);
}
}

View File

@ -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));
}
}

View File

@ -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;
}

View 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; }
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,3 @@
namespace API.DTOs.Settings;
public sealed record AuthorityValidationDto(string Authority);

View 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; }
}

View 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);
}

View 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;
}

View File

@ -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>

View File

@ -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; }

View File

@ -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

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -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

View File

@ -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");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View 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");
}
}
}

View File

@ -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");

View File

@ -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()

View File

@ -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();

View File

@ -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

View 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,
}

View File

@ -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,
}

View File

@ -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>

View File

@ -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 =>
{

View File

@ -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();
}
}

View 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;
}
}

View File

@ -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

View File

@ -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;
}
}

View File

@ -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..];
}
}

View File

@ -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>();
}
}

View File

@ -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:

View File

@ -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",

View File

@ -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
View 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;
}
}

View File

@ -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();
}

View File

@ -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);
}
}
}
}

View File

@ -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 &&

View 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;
}
}

View File

@ -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);

View File

@ -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--;
}

View File

@ -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))
{

View File

@ -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

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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": {

View File

@ -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
View 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
}
}

View File

@ -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;
})

View File

@ -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);

View File

@ -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);
}
}

View File

@ -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;
}

View 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[];
}

View File

@ -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];

View File

@ -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');

View 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');
}
}

View 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");
}
}
}

View 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');
}
}

View 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');
}
}
}

View File

@ -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) {

View File

@ -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});
/**

View File

@ -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');
}
}
/**

View File

@ -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>;

View 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;
}

View File

@ -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;
}

View File

@ -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>
}

View File

@ -0,0 +1,3 @@
.text-muted {
font-size: 0.75rem;
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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>

View File

@ -0,0 +1,3 @@
.text-muted {
font-size: 0.875rem;
}

View File

@ -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;
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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>

View File

@ -0,0 +1,8 @@
.invalid-feedback {
display: inherit;
}
.custom-position {
right: 5px;
top: -42px;
}

View File

@ -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}}
}
}
}

View File

@ -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>

View File

@ -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;
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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;
}

View File

@ -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 => {

View File

@ -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'));
}
}

View File

@ -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

View File

@ -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