Compare commits
29 commits
develop
...
feature/oi
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e5c716d234 | ||
![]() |
061fb222e6 | ||
![]() |
5e367418d1 | ||
![]() |
7cdf69533b | ||
![]() |
6cac60342b | ||
![]() |
6e72c74fde | ||
![]() |
b6bfc65bc4 | ||
![]() |
d2e1ca9078 | ||
![]() |
4c397e0af0 | ||
![]() |
626bb3b719 | ||
![]() |
63a5750f28 | ||
![]() |
f868f5df91 | ||
![]() |
9979220641 | ||
![]() |
08914f7546 | ||
![]() |
a122ae07a9 | ||
![]() |
9fb29dec20 | ||
![]() |
dc91696769 | ||
![]() |
e8f74709f3 | ||
![]() |
5104a66cae | ||
![]() |
7847ce4c1b | ||
![]() |
4c0faa755d | ||
![]() |
54fb4c7a8a | ||
![]() |
9f94abe1be | ||
![]() |
188020597c | ||
![]() |
1180d518a2 | ||
![]() |
5480df4cfb | ||
![]() |
0b64ea1622 | ||
![]() |
465723fedf | ||
![]() |
df9d970a42 |
79 changed files with 9497 additions and 181 deletions
|
@ -27,7 +27,7 @@ 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 UpdateMetadataSettings
|
||||
|
|
|
@ -52,6 +52,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 +61,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 +74,32 @@ public class AccountController : BaseApiController
|
|||
_emailService = emailService;
|
||||
_eventHub = eventHub;
|
||||
_localizationService = localizationService;
|
||||
_oidcService = oidcService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current user, as it would from login
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="UnauthorizedAccessException"></exception>
|
||||
/// <remarks>Also throws UnauthorizedAccessException if the users is missing the Login role</remarks>
|
||||
/// <remarks>Syncs Oidc settings if enabled, and user is Oidc owned</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();
|
||||
|
||||
if (user.Owner == AppUserOwner.OpenIdConnect)
|
||||
{
|
||||
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
await _oidcService.SyncUserSettings(oidcSettings, User, user);
|
||||
}
|
||||
|
||||
var roles = await _userManager.GetRolesAsync(user);
|
||||
if (!roles.Contains(PolicyConstants.LoginRole)) return Unauthorized(await _localizationService.Translate(user.Id, "disabled-account"));
|
||||
|
||||
return Ok(await ConstructUserDto(user));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -151,10 +179,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 +252,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
|
||||
|
@ -248,6 +281,11 @@ public class AccountController : BaseApiController
|
|||
}
|
||||
}
|
||||
|
||||
return Ok(await ConstructUserDto(user));
|
||||
}
|
||||
|
||||
private async Task<UserDto> ConstructUserDto(AppUser user)
|
||||
{
|
||||
// Update LastActive on account
|
||||
user.UpdateLastActive();
|
||||
|
||||
|
@ -268,12 +306,11 @@ public class AccountController : BaseApiController
|
|||
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>
|
||||
|
@ -505,6 +542,7 @@ public class AccountController : BaseApiController
|
|||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>OIDC managed users cannot be edited if SyncUsers is enabled</remarks>
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpPost("update")]
|
||||
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
|
||||
|
@ -517,6 +555,28 @@ 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"));
|
||||
|
||||
// Disallowed editing users owned by OIDC
|
||||
var oidcSettings = (await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
if (user.Owner == AppUserOwner.OpenIdConnect && dto.Owner != AppUserOwner.Native && oidcSettings.SyncUserSettings)
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "oidc-managed"));
|
||||
}
|
||||
|
||||
var defaultAdminUser = await _unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||
if (user.Id == defaultAdminUser.Id && dto.Owner != AppUserOwner.Native)
|
||||
{
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "cannot-change-ownership-original-user"));
|
||||
}
|
||||
|
||||
user.Owner = dto.Owner;
|
||||
|
||||
if (user.Owner == AppUserOwner.OpenIdConnect)
|
||||
{
|
||||
// Do not change any other fields when the user is owned by OIDC
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
// Check if username is changing
|
||||
if (!user.UserName!.Equals(dto.Username))
|
||||
{
|
||||
|
@ -670,10 +730,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 +832,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>
|
||||
|
|
45
API/Controllers/OidcControlller.cs
Normal file
45
API/Controllers/OidcControlller.cs
Normal file
|
@ -0,0 +1,45 @@
|
|||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Settings;
|
||||
using API.Services;
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Controllers;
|
||||
|
||||
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork,
|
||||
IMapper mapper, ISettingsService settingsService): BaseApiController
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve publicly required configuration regarding Oidc
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[AllowAnonymous]
|
||||
[HttpGet("config")]
|
||||
public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
|
||||
{
|
||||
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig));
|
||||
}
|
||||
|
||||
/// <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] IsValidAuthorityBody authority)
|
||||
{
|
||||
return Ok(await settingsService.IsValidAuthority(authority.Authority));
|
||||
}
|
||||
|
||||
public class IsValidAuthorityBody
|
||||
{
|
||||
public string Authority { get; set; }
|
||||
}
|
||||
|
||||
}
|
|
@ -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 AppUserOwner Owner { get; init; }
|
||||
}
|
||||
|
|
|
@ -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 AppUserOwner Owner { get; init; }
|
||||
}
|
||||
|
|
38
API/DTOs/Settings/OidcConfigDto.cs
Normal file
38
API/DTOs/Settings/OidcConfigDto.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
public record OidcConfigDto: OidcPublicConfigDto
|
||||
{
|
||||
/// <summary>
|
||||
/// If true, auto creates a new account when someone logs in via OpenID Connect
|
||||
/// </summary>
|
||||
public bool ProvisionAccounts { get; set; }
|
||||
/// <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 provides roles on log in.
|
||||
/// </summary>
|
||||
public bool SyncUserSettings { 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 DefaultAgeRating { 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 => Authority != "";
|
||||
}
|
25
API/DTOs/Settings/OidcPublicConfigDto.cs
Normal file
25
API/DTOs/Settings/OidcPublicConfigDto.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
#nullable enable
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs.Settings;
|
||||
|
||||
public record OidcPublicConfigDto
|
||||
{
|
||||
/// <inheritdoc cref="ServerSettingKey.OidcAuthority"/>
|
||||
public string? Authority { get; set; }
|
||||
/// <inheritdoc cref="ServerSettingKey.OidcClientId"/>
|
||||
public string? ClientId { get; set; }
|
||||
/// <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";
|
||||
}
|
|
@ -92,6 +92,11 @@ public sealed record ServerSettingDto
|
|||
/// SMTP Configuration
|
||||
/// </summary>
|
||||
public SmtpConfigDto SmtpConfig { get; set; }
|
||||
/// <summary>
|
||||
/// OIDC Configuration
|
||||
/// </summary>
|
||||
public OidcConfigDto OidcConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Date Kavita was first installed
|
||||
/// </summary>
|
||||
|
|
|
@ -22,6 +22,10 @@ public sealed record LibraryStatV3
|
|||
/// </summary>
|
||||
public bool CreateReadingListsFromMetadata { get; set; }
|
||||
/// <summary>
|
||||
/// If the library has metadata turned on
|
||||
/// </summary>
|
||||
public bool EnabledMetadata { get; set; }
|
||||
/// <summary>
|
||||
/// Type of the Library
|
||||
/// </summary>
|
||||
public LibraryType LibraryType { get; set; }
|
||||
|
|
|
@ -131,6 +131,10 @@ public sealed record ServerInfoV3Dto
|
|||
/// Is this server using Kavita+
|
||||
/// </summary>
|
||||
public bool ActiveKavitaPlusSubscription { get; set; }
|
||||
/// <summary>
|
||||
/// Is OIDC enabled
|
||||
/// </summary>
|
||||
public bool OidcEnabled { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Users
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using API.Data.Misc;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Device;
|
||||
|
||||
namespace API.DTOs.Stats.V3;
|
||||
|
@ -76,6 +77,10 @@ public sealed record UserStatV3
|
|||
/// Roles for this user
|
||||
/// </summary>
|
||||
public ICollection<string> Roles { get; set; }
|
||||
/// <summary>
|
||||
/// Who manages the user (OIDC, Kavita)
|
||||
/// </summary>
|
||||
public AppUserOwner Owner { get; set; }
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
|
||||
using System;
|
||||
using API.DTOs.Account;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
#nullable enable
|
||||
|
@ -15,4 +16,6 @@ public sealed record UserDto
|
|||
public UserPreferencesDto? Preferences { get; set; }
|
||||
public AgeRestrictionDto? AgeRestriction { get; init; }
|
||||
public string KavitaVersion { get; set; }
|
||||
/// <inheritdoc cref="AppUser.Owner"/>
|
||||
public AppUserOwner Owner { get; init; }
|
||||
}
|
||||
|
|
3574
API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs
generated
Normal file
3574
API/Data/Migrations/20250520073818_OpenIDConnect.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
28
API/Data/Migrations/20250520073818_OpenIDConnect.cs
Normal file
28
API/Data/Migrations/20250520073818_OpenIDConnect.cs
Normal file
|
@ -0,0 +1,28 @@
|
|||
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<string>(
|
||||
name: "ExternalId",
|
||||
table: "AspNetUsers",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExternalId",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
3728
API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs
generated
Normal file
3728
API/Data/Migrations/20250701154425_AppUserOwner.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
29
API/Data/Migrations/20250701154425_AppUserOwner.cs
Normal file
29
API/Data/Migrations/20250701154425_AppUserOwner.cs
Normal file
|
@ -0,0 +1,29 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AppUserOwner : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Owner",
|
||||
table: "AspNetUsers",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Owner",
|
||||
table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -87,6 +87,9 @@ namespace API.Data.Migrations
|
|||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasRunScrobbleEventGeneration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
@ -116,6 +119,9 @@ namespace API.Data.Migrations
|
|||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Owner")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -3637,7 +3643,8 @@ namespace API.Data.Migrations
|
|||
|
||||
b.Navigation("TableOfContents");
|
||||
|
||||
b.Navigation("UserPreferences");
|
||||
b.Navigation("UserPreferences")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("UserRoles");
|
||||
|
||||
|
|
|
@ -107,6 +107,7 @@ public interface IUserRepository
|
|||
Task<IList<AppUserSideNavStream>> GetDashboardStreamsByIds(IList<int> streamIds);
|
||||
Task<IEnumerable<UserTokenInfo>> GetUserTokenInfo();
|
||||
Task<AppUser?> GetUserByDeviceEmail(string deviceEmail);
|
||||
Task<AppUser?> GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None);
|
||||
}
|
||||
|
||||
public class UserRepository : IUserRepository
|
||||
|
@ -557,6 +558,16 @@ public class UserRepository : IUserRepository
|
|||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public async Task<AppUser?> GetByExternalId(string? externalId, AppUserIncludes includes = AppUserIncludes.None)
|
||||
{
|
||||
if (string.IsNullOrEmpty(externalId)) return null;
|
||||
|
||||
return await _context.AppUser
|
||||
.Where(u => u.ExternalId == externalId)
|
||||
.Includes(includes)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
|
||||
public async Task<IEnumerable<AppUser>> GetAdminUsersAsync()
|
||||
{
|
||||
|
@ -789,6 +800,7 @@ public class UserRepository : IUserRepository
|
|||
LastActiveUtc = u.LastActiveUtc,
|
||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||
IsPending = !u.EmailConfirmed,
|
||||
Owner = u.Owner,
|
||||
AgeRestriction = new AgeRestrictionDto()
|
||||
{
|
||||
AgeRating = u.AgeRestriction,
|
||||
|
@ -800,7 +812,7 @@ public class UserRepository : IUserRepository
|
|||
Type = l.Type,
|
||||
LastScanned = l.LastScanned,
|
||||
Folders = l.Folders.Select(x => x.Path).ToList()
|
||||
}).ToList()
|
||||
}).ToList(),
|
||||
})
|
||||
.AsSplitQuery()
|
||||
.AsNoTracking()
|
||||
|
|
|
@ -5,9 +5,11 @@ using System.Globalization;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using API.Constants;
|
||||
using API.Data.Repositories;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Entities.Enums.Theme;
|
||||
|
@ -252,6 +254,9 @@ 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.OidcAuthority, Value = Configuration.OidcAuthority},
|
||||
new() { Key = ServerSettingKey.OidcClientId, Value = Configuration.OidcClientId},
|
||||
new() { Key = ServerSettingKey.OidcConfiguration, Value = JsonSerializer.Serialize(new OidcConfigDto())},
|
||||
|
||||
new() {Key = ServerSettingKey.EmailHost, Value = string.Empty},
|
||||
new() {Key = ServerSettingKey.EmailPort, Value = string.Empty},
|
||||
|
@ -288,6 +293,10 @@ public static class Seed
|
|||
DirectoryService.BackupDirectory + string.Empty;
|
||||
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.CacheSize)).Value =
|
||||
Configuration.CacheSize + string.Empty;
|
||||
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcAuthority)).Value =
|
||||
Configuration.OidcAuthority + string.Empty;
|
||||
(await context.ServerSetting.FirstAsync(s => s.Key == ServerSettingKey.OidcClientId)).Value =
|
||||
Configuration.OidcClientId + string.Empty;
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
|
|
@ -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,16 @@ 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? ExternalId { get; set; }
|
||||
/// <summary>
|
||||
/// Describes who manages the account (may further depend on other settings)
|
||||
/// </summary>
|
||||
/// <remarks>Always fallbacks to native</remarks>
|
||||
public AppUserOwner Owner { get; set; } = AppUserOwner.Native;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A list of Series the user doesn't want scrobbling for
|
||||
|
|
16
API/Entities/Enums/AppUserOwner.cs
Normal file
16
API/Entities/Enums/AppUserOwner.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
namespace API.Entities.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Who own the user, can be updated in the UI if desired
|
||||
/// </summary>
|
||||
public enum AppUserOwner
|
||||
{
|
||||
/**
|
||||
* Kavita has full control over the user
|
||||
*/
|
||||
Native = 0,
|
||||
/**
|
||||
* The user is synced with the OIDC provider
|
||||
*/
|
||||
OpenIdConnect = 1,
|
||||
}
|
|
@ -197,4 +197,20 @@ public enum ServerSettingKey
|
|||
/// </summary>
|
||||
[Description("FirstInstallVersion")]
|
||||
FirstInstallVersion = 39,
|
||||
/// <summary>
|
||||
/// Optional OpenID Connect Authority URL
|
||||
/// </summary>
|
||||
[Description("OpenIDConnectAuthority")]
|
||||
OidcAuthority = 40,
|
||||
/// <summary>
|
||||
/// Optional OpenID Connect ClientId, default to kavita
|
||||
/// </summary>
|
||||
[Description("OpenIDConnectClientId")]
|
||||
OidcClientId = 41,
|
||||
/// <summary>
|
||||
/// A Json object of type <see cref="API.DTOs.Settings.OidcConfigDto"/>
|
||||
/// </summary>
|
||||
[Description("OidcConfiguration")]
|
||||
OidcConfiguration = 42,
|
||||
|
||||
}
|
||||
|
|
|
@ -83,6 +83,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);
|
||||
|
||||
|
|
|
@ -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,25 @@ 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 List<string> GetAccessRoles(this ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
return claimsPrincipal.FindAll(ClaimTypes.Role)
|
||||
.Select(r => r.Value)
|
||||
.Where(r => PolicyConstants.ValidRoles.Contains(r))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
@ -68,4 +69,9 @@ public static class EnumerableExtensions
|
|||
|
||||
return q;
|
||||
}
|
||||
|
||||
public static string AsJoinedString(this IEnumerable<IdentityError> errors)
|
||||
{
|
||||
return string.Join(",", errors.Select(e => e.Description));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,20 +1,36 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
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.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Services;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
|
||||
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext;
|
||||
|
||||
namespace API.Extensions;
|
||||
#nullable enable
|
||||
|
||||
public static class IdentityServiceExtensions
|
||||
{
|
||||
private const string DynamicJwt = nameof(DynamicJwt);
|
||||
private const string OpenIdConnect = nameof(OpenIdConnect);
|
||||
private const string LocalIdentity = nameof(LocalIdentity);
|
||||
|
||||
public static IServiceCollection AddIdentityServices(this IServiceCollection services, IConfiguration config)
|
||||
{
|
||||
services.Configure<IdentityOptions>(options =>
|
||||
|
@ -47,42 +63,146 @@ public static class IdentityServiceExtensions
|
|||
.AddRoleValidator<RoleValidator<AppRole>>()
|
||||
.AddEntityFrameworkStores<DataContext>();
|
||||
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
var auth = services.AddAuthentication(DynamicJwt)
|
||||
.AddPolicyScheme(DynamicJwt, JwtBearerDefaults.AuthenticationScheme, options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters()
|
||||
var iss = Configuration.OidcAuthority;
|
||||
var enabled = Configuration.OidcEnabled;
|
||||
options.ForwardDefaultSelector = context =>
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["TokenKey"]!)),
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidIssuer = "Kavita"
|
||||
};
|
||||
if (!enabled)
|
||||
return LocalIdentity;
|
||||
|
||||
options.Events = new JwtBearerEvents()
|
||||
{
|
||||
OnMessageReceived = context =>
|
||||
var fullAuth =
|
||||
context.Request.Headers["Authorization"].FirstOrDefault() ??
|
||||
context.Request.Query["access_token"].FirstOrDefault();
|
||||
|
||||
var token = fullAuth?.TrimPrefix("Bearer ");
|
||||
|
||||
if (string.IsNullOrEmpty(token))
|
||||
return LocalIdentity;
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
try
|
||||
{
|
||||
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;
|
||||
var jwt = handler.ReadJwtToken(token);
|
||||
if (jwt.Issuer == iss) return OpenIdConnect;
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* Swallow */
|
||||
}
|
||||
|
||||
return LocalIdentity;
|
||||
};
|
||||
});
|
||||
services.AddAuthorization(opt =>
|
||||
|
||||
if (Configuration.OidcEnabled)
|
||||
{
|
||||
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));
|
||||
// TODO: Investigate on how to make this not hardcoded at startup
|
||||
auth.AddJwtBearer(OpenIdConnect, options =>
|
||||
{
|
||||
options.Authority = Configuration.OidcAuthority;
|
||||
options.Audience = Configuration.OidcClientId;
|
||||
options.RequireHttpsMetadata = options.Authority.StartsWith("https://");
|
||||
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidAudience = Configuration.OidcClientId,
|
||||
ValidIssuer = Configuration.OidcAuthority,
|
||||
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
|
||||
ValidateIssuerSigningKey = true,
|
||||
RequireExpirationTime = true,
|
||||
ValidateLifetime = true,
|
||||
RequireSignedTokens = true
|
||||
};
|
||||
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = SetTokenFromQuery,
|
||||
OnTokenValidated = OidcClaimsPrincipalConverter,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
|
||||
{
|
||||
if (ctx.Principal == null) return;
|
||||
|
||||
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
|
||||
var user = await oidcService.LoginOrCreate(ctx.Principal);
|
||||
if (user == null)
|
||||
{
|
||||
ctx.Principal = null;
|
||||
await ctx.HttpContext.SignOutAsync(OpenIdConnect);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the following claims like Kavita expects them
|
||||
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 unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService<IUnitOfWork>();
|
||||
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||
if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings)
|
||||
{
|
||||
var userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
||||
}
|
||||
else
|
||||
{
|
||||
claims.AddRange(ctx.Principal.Claims);
|
||||
}
|
||||
|
||||
var identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
ctx.HttpContext.User = principal;
|
||||
ctx.Principal = principal;
|
||||
|
||||
ctx.Success();
|
||||
}
|
||||
|
||||
private static Task SetTokenFromQuery(MessageReceivedContext context)
|
||||
{
|
||||
var accessToken = context.Request.Query["access_token"];
|
||||
var path = context.HttpContext.Request.Path;
|
||||
// Only use query string based token on SignalR hubs
|
||||
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) context.Token = accessToken;
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,4 +52,13 @@ 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -386,7 +386,6 @@ public class AutoMapperProfiles : Profile
|
|||
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
|
||||
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
|
||||
|
||||
|
||||
|
||||
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using API.DTOs.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
|
@ -129,6 +130,21 @@ public class ServerSettingConverter : ITypeConverter<IEnumerable<ServerSetting>,
|
|||
case ServerSettingKey.FirstInstallVersion:
|
||||
destination.FirstInstallVersion = row.Value;
|
||||
break;
|
||||
case ServerSettingKey.OidcAuthority:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
destination.OidcConfig.Authority = row.Value;
|
||||
break;
|
||||
case ServerSettingKey.OidcClientId:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
destination.OidcConfig.ClientId = row.Value;
|
||||
break;
|
||||
case ServerSettingKey.OidcConfiguration:
|
||||
destination.OidcConfig ??= new OidcConfigDto();
|
||||
var configuration = JsonSerializer.Deserialize<OidcConfigDto>(row.Value)!;
|
||||
configuration.Authority = destination.OidcConfig.Authority;
|
||||
configuration.ClientId = destination.OidcConfig.ClientId;
|
||||
destination.OidcConfig = configuration;
|
||||
break;
|
||||
case ServerSettingKey.LicenseKey:
|
||||
case ServerSettingKey.EnableAuthentication:
|
||||
case ServerSettingKey.EmailServiceUrl:
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
"confirm-email": "You must confirm your email first",
|
||||
"locked-out": "You've been locked out from too many authorization attempts. Please wait 10 minutes.",
|
||||
"disabled-account": "Your account is disabled. Contact the server admin.",
|
||||
"password-authentication-disabled": "Password authentication has been disabled, login via OpenID Connect",
|
||||
"register-user": "Something went wrong when registering user",
|
||||
"validate-email": "There was an issue validating your email: {0}",
|
||||
"confirm-token-gen": "There was an issue generating a confirmation token",
|
||||
|
@ -17,6 +18,8 @@
|
|||
"generate-token": "There was an issue generating a confirmation email token. See logs",
|
||||
"age-restriction-update": "There was an error updating the age restriction",
|
||||
"no-user": "User does not exist",
|
||||
"oidc-managed": "This user is managed by OIDC, cannot edit",
|
||||
"cannot-change-ownership-original-user": "Ownership 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",
|
||||
|
|
|
@ -2,18 +2,20 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
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;
|
||||
|
@ -29,6 +31,19 @@ public interface IAccountService
|
|||
Task<bool> HasBookmarkPermission(AppUser? user);
|
||||
Task<bool> HasDownloadPermission(AppUser? user);
|
||||
Task<bool> CanChangeAgeRestriction(AppUser? user);
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="librariesIds"></param>
|
||||
/// <param name="hasAdminRole"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Ensure that the users SideNavStreams are loaded</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
|
||||
|
@ -36,13 +51,16 @@ public class AccountService : IAccountService
|
|||
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 AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork)
|
||||
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger, IUnitOfWork unitOfWork,
|
||||
IMapper mapper)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword)
|
||||
|
@ -143,4 +161,79 @@ public class AccountService : IAccountService
|
|||
|
||||
return roles.Contains(PolicyConstants.ChangePasswordRole) || roles.Contains(PolicyConstants.AdminRole);
|
||||
}
|
||||
|
||||
public async Task UpdateLibrariesForUser(AppUser user, IList<int> librariesIds, bool hasAdminRole)
|
||||
{
|
||||
var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync(LibraryIncludes.AppUser)).ToList();
|
||||
List<Library> libraries;
|
||||
if (hasAdminRole)
|
||||
{
|
||||
_logger.LogInformation("{UserName} is admin. Granting access to all libraries",
|
||||
user.UserName);
|
||||
libraries = allLibraries;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove user from all libraries
|
||||
foreach (var lib in allLibraries)
|
||||
{
|
||||
lib.AppUsers ??= new List<AppUser>();
|
||||
lib.AppUsers.Remove(user);
|
||||
user.RemoveSideNavFromLibrary(lib);
|
||||
}
|
||||
|
||||
libraries = allLibraries.Where(lib => librariesIds.Contains(lib.Id)).ToList();
|
||||
}
|
||||
|
||||
foreach (var lib in libraries)
|
||||
{
|
||||
lib.AppUsers ??= new List<AppUser>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
271
API/Services/OidcService.cs
Normal file
271
API/Services/OidcService.cs
Normal file
|
@ -0,0 +1,271 @@
|
|||
#nullable enable
|
||||
using System;
|
||||
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.Settings;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers.Builders;
|
||||
using Kavita.Common;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services;
|
||||
|
||||
public interface IOidcService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the user authenticated with OpenID Connect
|
||||
/// </summary>
|
||||
/// <param name="principal"></param>
|
||||
/// <returns></returns>
|
||||
/// <exception cref="KavitaException">if any requirements aren't met</exception>
|
||||
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
|
||||
/// <summary>
|
||||
/// Updates roles, library access and age rating. Will not modify the default admin
|
||||
/// </summary>
|
||||
/// <param name="settings"></param>
|
||||
/// <param name="claimsPrincipal"></param>
|
||||
/// <param name="user"></param>
|
||||
Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user);
|
||||
/// <summary>
|
||||
/// Remove <see cref="AppUser.ExternalId"/> from all users
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task ClearOidcIds();
|
||||
}
|
||||
|
||||
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
|
||||
IUnitOfWork unitOfWork, IAccountService accountService): IOidcService
|
||||
{
|
||||
private const string LibraryAccessPrefix = "library-";
|
||||
private const string AgeRatingPrefix = "age-rating-";
|
||||
private const string IncludeUnknowns = AgeRatingPrefix + "include-unknowns";
|
||||
|
||||
public async Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal)
|
||||
{
|
||||
var settings = (await unitOfWork.SettingsRepository.GetSettingsDtoAsync()).OidcConfig;
|
||||
|
||||
var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(externalId))
|
||||
throw new KavitaException("errors.oidc.missing-external-id");
|
||||
|
||||
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, 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)
|
||||
{
|
||||
logger.LogInformation("User {UserName} has matched on email to {ExternalId}", user.Id, externalId);
|
||||
user.ExternalId = externalId;
|
||||
await unitOfWork.CommitAsync();
|
||||
return user;
|
||||
}
|
||||
|
||||
// Cannot match on native account, try and create new one
|
||||
if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0)
|
||||
throw new KavitaException("errors.oidc.role-not-assigned");
|
||||
|
||||
user = await NewUserFromOpenIdConnect(settings, principal, externalId);
|
||||
if (user == null) return null;
|
||||
|
||||
var roles = await userManager.GetRolesAsync(user);
|
||||
if (roles.Count == 0 || !roles.Contains(PolicyConstants.LoginRole))
|
||||
throw new KavitaException("errors.oidc.disabled-account");
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
public async Task ClearOidcIds()
|
||||
{
|
||||
var users = await unitOfWork.UserRepository.GetAllUsersAsync();
|
||||
foreach (var user in users)
|
||||
{
|
||||
user.ExternalId = null;
|
||||
}
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private async Task<string?> FindBestAvailableName(ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername);
|
||||
if (await IsNameAvailable(name)) return name;
|
||||
|
||||
name = claimsPrincipal.FindFirstValue(ClaimTypes.Name);
|
||||
if (await IsNameAvailable(name)) return name;
|
||||
|
||||
name = claimsPrincipal.FindFirstValue(ClaimTypes.GivenName);
|
||||
if (await IsNameAvailable(name)) return name;
|
||||
|
||||
name = claimsPrincipal.FindFirstValue(ClaimTypes.Surname);
|
||||
if (await IsNameAvailable(name)) return name;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<bool> IsNameAvailable(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return false;
|
||||
|
||||
return await userManager.FindByNameAsync(name) == null;
|
||||
}
|
||||
|
||||
private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, string externalId)
|
||||
{
|
||||
if (!settings.ProvisionAccounts) return null;
|
||||
|
||||
var emailClaim = claimsPrincipal.FindFirst(ClaimTypes.Email);
|
||||
if (emailClaim == null || string.IsNullOrWhiteSpace(emailClaim.Value)) return null;
|
||||
|
||||
var name = await FindBestAvailableName(claimsPrincipal) ?? emailClaim.Value;
|
||||
logger.LogInformation("Creating new user from OIDC: {Name} - {ExternalId}", name, externalId);
|
||||
|
||||
// TODO: Move to account service, as we're sharing code with AccountController
|
||||
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 (settings.RequireVerifiedEmail)
|
||||
{
|
||||
// Email has been verified by OpenID Connect provider
|
||||
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
|
||||
await userManager.ConfirmEmailAsync(user, token);
|
||||
}
|
||||
|
||||
user.ExternalId = externalId;
|
||||
user.Owner = AppUserOwner.OpenIdConnect;
|
||||
|
||||
accountService.AddDefaultStreamsToUser(user);
|
||||
await accountService.AddDefaultReadingProfileToUser(user);
|
||||
|
||||
await SyncUserSettings(settings, claimsPrincipal, user);
|
||||
await SetDefaults(settings, user);
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
return user;
|
||||
}
|
||||
|
||||
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.DefaultAgeRating, 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.DefaultAgeRating;
|
||||
user.AgeRestrictionIncludeUnknowns = settings.DefaultIncludeUnknowns;
|
||||
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
public async Task SyncUserSettings(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.LogInformation("Syncing user {UserName} from OIDC", user.UserName);
|
||||
await SyncRoles(claimsPrincipal, user);
|
||||
await SyncLibraries(claimsPrincipal, user);
|
||||
await SyncAgeRestriction(claimsPrincipal, user);
|
||||
|
||||
|
||||
if (unitOfWork.HasChanges())
|
||||
await unitOfWork.CommitAsync();
|
||||
}
|
||||
|
||||
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var roles = claimsPrincipal.GetAccessRoles();
|
||||
logger.LogDebug("Syncing access roles for user {UserName}, found roles {Roles}", user.UserName, roles);
|
||||
|
||||
var errors = await accountService.UpdateRolesForUser(user, roles);
|
||||
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
|
||||
}
|
||||
|
||||
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
var libraryAccess = claimsPrincipal
|
||||
.FindAll(ClaimTypes.Role)
|
||||
.Where(r => r.Value.StartsWith(LibraryAccessPrefix))
|
||||
.Select(r => r.Value.TrimPrefix(LibraryAccessPrefix))
|
||||
.ToList();
|
||||
|
||||
logger.LogDebug("Syncing libraries for user {UserName}, found library roles {Roles}", user.UserName, libraryAccess);
|
||||
|
||||
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).ToList();
|
||||
|
||||
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
|
||||
}
|
||||
|
||||
private async Task SyncAgeRestriction(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||
{
|
||||
if (await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
|
||||
{
|
||||
logger.LogInformation("User {UserName} is admin, granting access to all age ratings", user.UserName);
|
||||
user.AgeRestriction = AgeRating.NotApplicable;
|
||||
user.AgeRestrictionIncludeUnknowns = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var ageRatings = claimsPrincipal
|
||||
.FindAll(ClaimTypes.Role)
|
||||
.Where(r => r.Value.StartsWith(AgeRatingPrefix))
|
||||
.Select(r => r.Value.TrimPrefix(AgeRatingPrefix))
|
||||
.ToList();
|
||||
logger.LogDebug("Syncing age restriction for user {UserName}, found restrictions {Restrictions}", user.UserName, ageRatings);
|
||||
|
||||
var highestAgeRating = AgeRating.Unknown;
|
||||
|
||||
foreach (var ar in ageRatings)
|
||||
{
|
||||
if (!Enum.TryParse(ar, out AgeRating ageRating)) continue;
|
||||
|
||||
if (ageRating > highestAgeRating)
|
||||
{
|
||||
highestAgeRating = ageRating;
|
||||
}
|
||||
}
|
||||
|
||||
user.AgeRestriction = highestAgeRating;
|
||||
user.AgeRestrictionIncludeUnknowns = ageRatings.Contains(IncludeUnknowns);
|
||||
|
||||
logger.LogDebug("Synced age restriction for user {UserName}, AgeRestriction {AgeRestriction}, IncludeUnknowns: {IncludeUnknowns}",
|
||||
user.UserName, ageRatings, user.AgeRestrictionIncludeUnknowns);
|
||||
}
|
||||
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.KavitaPlus.Metadata;
|
||||
|
@ -10,12 +11,13 @@ using API.Entities.Enums;
|
|||
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;
|
||||
|
||||
|
@ -23,6 +25,12 @@ public interface ISettingsService
|
|||
{
|
||||
Task<MetadataSettingsDto> UpdateMetadataSettings(MetadataSettingsDto dto);
|
||||
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 +41,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>
|
||||
|
@ -172,7 +182,7 @@ public class SettingsService : ISettingsService
|
|||
updateTask = updateTask || UpdateSchedulingSettings(setting, updateSettingsDto);
|
||||
|
||||
UpdateEmailSettings(setting, updateSettingsDto);
|
||||
|
||||
await UpdateOidcSettings(setting, updateSettingsDto);
|
||||
|
||||
|
||||
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
|
||||
|
@ -346,6 +356,27 @@ public class SettingsService : ISettingsService
|
|||
return updateSettingsDto;
|
||||
}
|
||||
|
||||
public async Task<bool> IsValidAuthority(string authority)
|
||||
{
|
||||
if (string.IsNullOrEmpty(authority))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var url = authority + "/.well-known/openid-configuration";
|
||||
try
|
||||
{
|
||||
var json = await url.GetStringAsync();
|
||||
var config = OpenIdConnectConfiguration.Create(json);
|
||||
return config.Issuer == Configuration.OidcAuthority;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogTrace(e, "OpenIdConfiguration failed: {Reason}", e.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateBookmarkDirectory(string originalBookmarkDirectory, string bookmarkDirectory)
|
||||
{
|
||||
_directoryService.ExistOrCreate(bookmarkDirectory);
|
||||
|
@ -379,6 +410,41 @@ public class SettingsService : ISettingsService
|
|||
return false;
|
||||
}
|
||||
|
||||
private async Task UpdateOidcSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.OidcAuthority && setting.Value != updateSettingsDto.OidcConfig.Authority)
|
||||
{
|
||||
if (!await IsValidAuthority(updateSettingsDto.OidcConfig.Authority + string.Empty))
|
||||
{
|
||||
throw new KavitaException("oidc-invalid-authority");
|
||||
}
|
||||
|
||||
setting.Value = updateSettingsDto.OidcConfig.Authority;
|
||||
Configuration.OidcAuthority = updateSettingsDto.OidcConfig.Authority;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
|
||||
_logger.LogWarning("OIDC Authority is changing, clearing all external ids");
|
||||
await _oidcService.ClearOidcIds();
|
||||
return;
|
||||
}
|
||||
|
||||
if (setting.Key == ServerSettingKey.OidcClientId && setting.Value != updateSettingsDto.OidcConfig.ClientId)
|
||||
{
|
||||
setting.Value = updateSettingsDto.OidcConfig.ClientId;
|
||||
Configuration.OidcClientId = updateSettingsDto.OidcConfig.ClientId;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setting.Key != ServerSettingKey.OidcConfiguration) return;
|
||||
|
||||
var newValue = JsonSerializer.Serialize(updateSettingsDto.OidcConfig);
|
||||
if (setting.Value == newValue) return;
|
||||
|
||||
setting.Value = newValue;
|
||||
_unitOfWork.SettingsRepository.Update(setting);
|
||||
}
|
||||
|
||||
private void UpdateEmailSettings(ServerSetting setting, ServerSettingDto updateSettingsDto)
|
||||
{
|
||||
if (setting.Key == ServerSettingKey.EmailHost &&
|
||||
|
|
|
@ -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.Owner = user.Owner;
|
||||
|
||||
if (allLibraries.Count > 0 && userLibraryAccess.TryGetValue(user.Id, out var accessibleLibraries))
|
||||
{
|
||||
|
|
|
@ -14,6 +14,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 +52,20 @@ public static class Configuration
|
|||
set => SetCacheSize(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
public static string OidcAuthority
|
||||
{
|
||||
get => GetOidcAuthority(GetAppSettingFilename());
|
||||
set => SetOidcAuthority(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
public static string OidcClientId
|
||||
{
|
||||
get => GetOidcClientId(GetAppSettingFilename());
|
||||
set => SetOidcClientId(GetAppSettingFilename(), value);
|
||||
}
|
||||
|
||||
public static bool OidcEnabled => GetOidcAuthority(GetAppSettingFilename()) != "";
|
||||
|
||||
public static bool AllowIFraming => GetAllowIFraming(GetAppSettingFilename());
|
||||
|
||||
private static string GetAppSettingFilename()
|
||||
|
@ -312,6 +328,74 @@ public static class Configuration
|
|||
}
|
||||
#endregion
|
||||
|
||||
#region OIDC
|
||||
|
||||
private static string GetOidcAuthority(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
return jsonObj.OidcAuthority;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static void SetOidcAuthority(string filePath, string authority)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
jsonObj.OidcAuthority = authority;
|
||||
json = JsonSerializer.Serialize(jsonObj, new JsonSerializerOptions { WriteIndented = true });
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
/* Swallow exception */
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetOidcClientId(string filePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
return jsonObj.OidcAudience;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Error reading app settings: " + ex.Message);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static void SetOidcClientId(string filePath, string audience)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var jsonObj = JsonSerializer.Deserialize<AppSettings>(json);
|
||||
jsonObj.OidcAudience = audience;
|
||||
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 +410,8 @@ public static class Configuration
|
|||
public long Cache { get; set; } = DefaultCacheMemory;
|
||||
// ReSharper disable once MemberHidesStaticFromOuterClass
|
||||
public bool AllowIFraming { get; init; } = false;
|
||||
public string OidcAuthority { get; set; } = DefaultOidcAuthority;
|
||||
public string OidcAudience { get; set; } = DefaultOidcClientId;
|
||||
#pragma warning restore S3218
|
||||
}
|
||||
}
|
||||
|
|
29
UI/Web/package-lock.json
generated
29
UI/Web/package-lock.json
generated
|
@ -33,6 +33,7 @@
|
|||
"@siemens/ngx-datatable": "^22.4.1",
|
||||
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"angular-oauth2-oidc": "^19.0.0",
|
||||
"bootstrap": "^5.3.2",
|
||||
"charts.css": "^1.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
|
@ -541,7 +542,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 +569,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 +583,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"
|
||||
},
|
||||
|
@ -4378,6 +4376,19 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/angular-oauth2-oidc": {
|
||||
"version": "19.0.0",
|
||||
"resolved": "https://registry.npmjs.org/angular-oauth2-oidc/-/angular-oauth2-oidc-19.0.0.tgz",
|
||||
"integrity": "sha512-EogHyF7MpCJSjSKIyVmdB8pJu7dU5Ilj9VNVSnFbLng4F77PIlaE4egwKUlUvk0i4ZvmO9rLXNQCm05R7Tyhcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.5.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=19.0.0",
|
||||
"@angular/core": ">=19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
|
||||
|
@ -4906,8 +4917,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 +5364,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 +5373,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 +8189,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 +8410,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 +8475,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 +9099,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"
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
"@siemens/ngx-datatable": "^22.4.1",
|
||||
"@swimlane/ngx-charts": "^22.0.0-alpha.0",
|
||||
"@tweenjs/tween.js": "^25.0.0",
|
||||
"angular-oauth2-oidc": "^19.0.0",
|
||||
"bootstrap": "^5.3.2",
|
||||
"charts.css": "^1.1.0",
|
||||
"file-saver": "^2.0.5",
|
||||
|
|
|
@ -16,7 +16,7 @@ export class JwtInterceptor implements HttpInterceptor {
|
|||
if (user) {
|
||||
request = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${user.token}`
|
||||
Authorization: `Bearer ${user.oidcToken ?? user.token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {AgeRestriction} from '../metadata/age-restriction';
|
||||
import {Library} from '../library/library';
|
||||
import {UserOwner} from "../user";
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
|
@ -13,4 +14,5 @@ export interface Member {
|
|||
libraries: Library[];
|
||||
ageRestriction: AgeRestriction;
|
||||
isPending: boolean;
|
||||
owner: UserOwner;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,9 @@ import {Preferences} from './preferences/preferences';
|
|||
// This interface is only used for login and storing/retrieving JWT from local storage
|
||||
export interface User {
|
||||
username: string;
|
||||
// This is set by the oidc service, will always take precedence over the Kavita generated token
|
||||
// When set, the refresh logic for the Kavita token will not run
|
||||
oidcToken?: string;
|
||||
token: string;
|
||||
refreshToken: string;
|
||||
roles: string[];
|
||||
|
@ -13,4 +16,12 @@ export interface User {
|
|||
ageRestriction: AgeRestriction;
|
||||
hasRunScrobbleEventGeneration: boolean;
|
||||
scrobbleEventGenerationRan: string; // datetime
|
||||
owner: UserOwner,
|
||||
}
|
||||
|
||||
export enum UserOwner {
|
||||
Native = 0,
|
||||
OpenIdConnect = 1,
|
||||
}
|
||||
|
||||
export const UserOwners: UserOwner[] = [UserOwner.Native, UserOwner.OpenIdConnect];
|
||||
|
|
|
@ -12,13 +12,17 @@ export class AgeRatingPipe implements PipeTransform {
|
|||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: AgeRating | AgeRatingDto | undefined): string {
|
||||
transform(value: AgeRating | AgeRatingDto | undefined | string): string {
|
||||
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
||||
if (value.hasOwnProperty('title')) {
|
||||
return (value as AgeRatingDto).title;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = parseInt(value, 10) as AgeRating;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case AgeRating.Unknown:
|
||||
return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
|
19
UI/Web/src/app/_pipes/user-owner.pipe.ts
Normal file
19
UI/Web/src/app/_pipes/user-owner.pipe.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {UserOwner} from "../_models/user";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'userOwnerPipe'
|
||||
})
|
||||
export class UserOwnerPipe implements PipeTransform {
|
||||
|
||||
transform(value: UserOwner, ...args: unknown[]): string {
|
||||
switch (value) {
|
||||
case UserOwner.Native:
|
||||
return translate("creation-source-pipe.native");
|
||||
case UserOwner.OpenIdConnect:
|
||||
return translate("creation-source-pipe.oidc");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
26
UI/Web/src/app/_resolvers/oidc.resolver.ts
Normal file
26
UI/Web/src/app/_resolvers/oidc.resolver.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {ActivatedRouteSnapshot, Resolve, Router, RouterStateSnapshot} from '@angular/router';
|
||||
import {inject, Injectable} from "@angular/core";
|
||||
import {catchError, filter, Observable, of, take, timeout} from "rxjs";
|
||||
import {OidcService} from "../_services/oidc.service";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OidcResolver implements Resolve<any> {
|
||||
|
||||
private oidcService = inject(OidcService);
|
||||
private toastR = inject(ToastrService);
|
||||
|
||||
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<any> {
|
||||
return this.oidcService.loaded$.pipe(
|
||||
filter(value => value),
|
||||
take(1),
|
||||
timeout(5000),
|
||||
catchError(err => {
|
||||
console.log(err);
|
||||
this.toastR.error("oidc.timeout");
|
||||
return of(true);
|
||||
}));
|
||||
}
|
||||
}
|
9
UI/Web/src/app/_routes/oidc-routing.module.ts
Normal file
9
UI/Web/src/app/_routes/oidc-routing.module.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import {Routes} from "@angular/router";
|
||||
import {OidcCallbackComponent} from "../registration/oidc-callback/oidc-callback.component";
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'callback',
|
||||
component: OidcCallbackComponent,
|
||||
}
|
||||
];
|
|
@ -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,6 +63,8 @@ export class AccountService {
|
|||
return this.hasAdminRole(u);
|
||||
}), shareReplay({bufferSize: 1, refCount: true}));
|
||||
|
||||
public currentUserSignal = toSignal(this.currentUserSource);
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
@ -205,6 +207,22 @@ export class AccountService {
|
|||
);
|
||||
}
|
||||
|
||||
loginByToken(token: string) {
|
||||
const headers = new HttpHeaders({
|
||||
"Authorization": `Bearer ${token}`
|
||||
})
|
||||
return this.httpClient.get<User>(this.baseUrl + 'account', {headers}).pipe(
|
||||
tap((response: User) => {
|
||||
const user = response;
|
||||
if (user) {
|
||||
user.oidcToken = token;
|
||||
this.setCurrentUser(user);
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentUser(user?: User, refreshConnections = true) {
|
||||
|
||||
const isSameUser = this.currentUser === user;
|
||||
|
@ -240,7 +258,10 @@ export class AccountService {
|
|||
this.messageHub.createHubConnection(this.currentUser);
|
||||
this.licenseService.hasValidLicense().subscribe();
|
||||
}
|
||||
this.startRefreshTokenTimer();
|
||||
// oidc handles refreshing itself
|
||||
if (!this.currentUser.oidcToken) {
|
||||
this.startRefreshTokenTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -165,7 +165,7 @@ export class MessageHubService {
|
|||
createHubConnection(user: User) {
|
||||
this.hubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.hubUrl + 'messages', {
|
||||
accessTokenFactory: () => user.token
|
||||
accessTokenFactory: () => user.oidcToken ?? user.token
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
//.withStatefulReconnect() // Requires signalr@8.0
|
||||
|
|
|
@ -11,6 +11,7 @@ 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 {OidcService} from "./oidc.service";
|
||||
|
||||
/**
|
||||
* NavItem used to construct the dropdown or NavLinkModal on mobile
|
||||
|
@ -34,6 +35,7 @@ interface NavItem {
|
|||
export class NavService {
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly oidcService = inject(OidcService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
|
@ -173,12 +175,28 @@ export class NavService {
|
|||
}
|
||||
|
||||
logout() {
|
||||
this.oidcService.logout();
|
||||
this.accountService.logout();
|
||||
this.hideNavBar();
|
||||
this.hideSideNav();
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
|
||||
handleLogin() {
|
||||
this.showNavBar();
|
||||
this.showSideNav();
|
||||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
|
||||
if (pageResume && pageResume !== '/login') {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl(pageResume);
|
||||
} else {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl('/home');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
|
||||
*/
|
||||
|
|
128
UI/Web/src/app/_services/oidc.service.ts
Normal file
128
UI/Web/src/app/_services/oidc.service.ts
Normal file
|
@ -0,0 +1,128 @@
|
|||
import {computed, DestroyRef, inject, Injectable, signal} from '@angular/core';
|
||||
import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc";
|
||||
import {from} from "rxjs";
|
||||
import {HttpClient} from "@angular/common/http";
|
||||
import {environment} from "../../environments/environment";
|
||||
import {OidcPublicConfig} from "../admin/_models/oidc-config";
|
||||
import {takeUntilDestroyed, toObservable} from "@angular/core/rxjs-interop";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
/**
|
||||
* Enum mirror of angular-oauth2-oidc events which are used in Kavita
|
||||
*/
|
||||
export enum OidcEvents {
|
||||
/**
|
||||
* Fired on token refresh, and when the first token is recieved
|
||||
*/
|
||||
TokenRefreshed = "token_refreshed"
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OidcService {
|
||||
|
||||
private readonly oauth2 = inject(OAuthService);
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly toastR = inject(ToastrService);
|
||||
|
||||
protected readonly baseUrl = inject(APP_BASE_HREF);
|
||||
apiBaseUrl = environment.apiUrl;
|
||||
|
||||
public events$ = this.oauth2.events;
|
||||
|
||||
/**
|
||||
* True when the OIDC discovery document has been loaded, and login tried. Or no OIDC has been set up
|
||||
*/
|
||||
private readonly _loaded = signal(false);
|
||||
public readonly loaded = this._loaded.asReadonly();
|
||||
public readonly loaded$ = toObservable(this.loaded);
|
||||
|
||||
public readonly inUse = computed(() => {
|
||||
const loaded = this.loaded();
|
||||
const settings = this.settings();
|
||||
return loaded && settings && settings.authority.trim() !== '';
|
||||
});
|
||||
|
||||
/**
|
||||
* Public OIDC settings
|
||||
*/
|
||||
private readonly _settings = signal<OidcPublicConfig | undefined>(undefined);
|
||||
public readonly settings = this._settings.asReadonly();
|
||||
|
||||
constructor() {
|
||||
this.oauth2.setStorage(localStorage);
|
||||
|
||||
// log events in dev
|
||||
if (!environment.production) {
|
||||
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
|
||||
if (event instanceof OAuthErrorEvent) {
|
||||
console.error('OAuthErrorEvent:', event);
|
||||
} else {
|
||||
console.debug('OAuthEvent:', event);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.getPublicOidcConfig().subscribe(oidcSetting => {
|
||||
this._settings.set(oidcSetting);
|
||||
|
||||
if (!oidcSetting.authority) {
|
||||
this._loaded.set(true);
|
||||
return
|
||||
}
|
||||
|
||||
this.oauth2.configure({
|
||||
issuer: oidcSetting.authority,
|
||||
clientId: oidcSetting.clientId,
|
||||
// Require https in production unless localhost
|
||||
requireHttps: environment.production ? 'remoteOnly' : false,
|
||||
redirectUri: window.location.origin + this.baseUrl + "oidc/callback",
|
||||
postLogoutRedirectUri: window.location.origin + this.baseUrl + "login",
|
||||
showDebugInformation: !environment.production,
|
||||
responseType: 'code',
|
||||
scope: "openid profile email roles offline_access",
|
||||
// Not all OIDC providers follow this nicely
|
||||
strictDiscoveryDocumentValidation: false,
|
||||
});
|
||||
this.oauth2.setupAutomaticSilentRefresh();
|
||||
|
||||
from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({
|
||||
next: _ => {
|
||||
this._loaded.set(true);
|
||||
|
||||
if (!this.oauth2.hasValidAccessToken() && this.oauth2.getRefreshToken()) {
|
||||
this.oauth2.refreshToken().catch(err => console.error("failed to refresh token on startup", err));
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.log(error);
|
||||
this.toastR.error(translate("oidc.error-loading-info"))
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
login() {
|
||||
this.oauth2.initLoginFlow();
|
||||
}
|
||||
|
||||
logout() {
|
||||
if (this.token) {
|
||||
this.oauth2.logOut();
|
||||
}
|
||||
}
|
||||
|
||||
getPublicOidcConfig() {
|
||||
return this.httpClient.get<OidcPublicConfig>(this.apiBaseUrl + "oidc/config");
|
||||
}
|
||||
|
||||
get token() {
|
||||
return this.oauth2.getAccessToken();
|
||||
}
|
||||
|
||||
}
|
20
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
20
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
|
||||
export interface OidcPublicConfig {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
autoLogin: boolean;
|
||||
disablePasswordAuthentication: boolean;
|
||||
providerName: string;
|
||||
}
|
||||
|
||||
export interface OidcConfig extends OidcPublicConfig {
|
||||
provisionAccounts: boolean;
|
||||
requireVerifiedEmail: boolean;
|
||||
syncUserSettings: boolean;
|
||||
defaultRoles: string[];
|
||||
defaultLibraries: number[];
|
||||
defaultAgeRating: AgeRating;
|
||||
defaultIncludeUnknowns: boolean;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import {EncodeFormat} from "./encode-format";
|
||||
import {CoverImageSize} from "./cover-image-size";
|
||||
import {SmtpConfig} from "./smtp-config";
|
||||
import {OidcConfig} from "./oidc-config";
|
||||
|
||||
export interface ServerSettings {
|
||||
cacheDirectory: string;
|
||||
|
@ -25,6 +26,7 @@ export interface ServerSettings {
|
|||
onDeckUpdateDays: number;
|
||||
coverImageSize: CoverImageSize;
|
||||
smtpConfig: SmtpConfig;
|
||||
oidcConfig: OidcConfig;
|
||||
installId: string;
|
||||
installVersion: string;
|
||||
}
|
||||
|
|
|
@ -1,17 +1,41 @@
|
|||
<ng-container *transloco="let t; read: 'edit-user'">
|
||||
<ng-container *transloco="let t; prefix: 'edit-user'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member().username | sentenceCase}}</h5>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
||||
@if (!isLocked() && member().owner === UserOwner.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('owner'); as formControl) {
|
||||
<label for="owner" class="form-label">{{t('owner')}}</label>
|
||||
<select class="form-select" id="owner" formControlName="owner">
|
||||
@for (owner of UserOwners; track owner) {
|
||||
<option [value]="owner">{{owner | userOwnerPipe}}</option>
|
||||
}
|
||||
</select>
|
||||
<span class="text-muted">{{t('owner-tooltip')}}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-4">
|
||||
@if(userForm.get('username'); as formControl) {
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{t('username')}}</label>
|
||||
|
@ -33,7 +57,7 @@
|
|||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
@if(userForm.get('email'); as formControl) {
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
|
@ -63,17 +87,17 @@
|
|||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member()"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member()"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member()"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -83,7 +107,7 @@
|
|||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isLocked() || isSaving || !userForm.valid">
|
||||
@if (isSaving) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.text-muted {
|
||||
font-size: 12px;
|
||||
}
|
|
@ -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,11 +18,14 @@ 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 {UserOwner, UserOwners} from "../../_models/user";
|
||||
import {UserOwnerPipe} from "../../_pipes/user-owner.pipe";
|
||||
|
||||
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
|
||||
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
@ -22,7 +34,7 @@ const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|||
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, UserOwnerPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
|
@ -32,7 +44,16 @@ export class EditUserComponent implements OnInit {
|
|||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input({required: true}) member!: Member;
|
||||
|
||||
// Needs to be models, so we can set it manually
|
||||
member = model.required<Member>();
|
||||
settings = model.required<ServerSettings>();
|
||||
|
||||
isLocked = computed(() => {
|
||||
const setting = this.settings();
|
||||
const member = this.member();
|
||||
return setting.oidcConfig.syncUserSettings && member.owner === UserOwner.OpenIdConnect;
|
||||
});
|
||||
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
|
@ -52,18 +73,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('owner', new FormControl(this.member().owner, [Validators.required]));
|
||||
|
||||
this.userForm.get('owner')!.valueChanges.pipe(
|
||||
tap(value => {
|
||||
const newOwner = parseInt(value, 10) as UserOwner;
|
||||
if (newOwner === UserOwner.OpenIdConnect) return;
|
||||
this.member.set({
|
||||
...this.member(),
|
||||
owner: newOwner,
|
||||
})
|
||||
})).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 +120,18 @@ 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.owner = parseInt(model.owner, 10) as UserOwner;
|
||||
|
||||
|
||||
this.accountService.update(model).subscribe(() => {
|
||||
this.modal.close(true);
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly UserOwner = UserOwner;
|
||||
protected readonly UserOwners = UserOwners;
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
inject, input,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
|
@ -29,6 +29,7 @@ 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 +62,14 @@ export class LibrarySelectorComponent implements OnInit {
|
|||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
this.selected.emit(this.selections.selected());
|
||||
} else if (this.preselectedLibraries().length > 0) {
|
||||
this.preselectedLibraries().forEach((id) => {
|
||||
const foundLib = this.allLibraries.find(lib => lib.id === id);
|
||||
if (foundLib) {
|
||||
this.selections.toggle(foundLib, true, (a, b) => a.name === b.name);
|
||||
}
|
||||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,195 @@
|
|||
<ng-container *transloco="let t; prefix:'oidc.settings'">
|
||||
|
||||
<div class="position-relative">
|
||||
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save()">{{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')}}</h4>
|
||||
<div class="text-muted">{{t('manual-save')}}</div>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('authority'); as formControl) {
|
||||
<app-setting-item [title]="t('authority')" [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="oidc-authority-validations" class="invalid-feedback">
|
||||
@if (formControl.errors?.invalidUri) {
|
||||
<div>{{t('invalidUri')}}</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('clientId')" [subtitle]="t('clientId-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-clientId" aria-describedby="oidc-clientId-validations" class="form-control"
|
||||
formControlName="clientId" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="oidc-clientId-validations" class="invalid-feedback">
|
||||
@if (formControl.errors?.requiredIf) {
|
||||
<div>{{t('field-required', {name: 'clientId', other: formControl.errors?.requiredIf.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('behaviour')}}</h4>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('providerName'); as formControl) {
|
||||
<app-setting-item [title]="t('providerName')" [subtitle]="t('providerName-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-providerName" aria-describedby="oidc-providerName-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('provisionAccounts')" [subtitle]="t('provisionAccounts-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="provisionAccounts" 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('requireVerifiedEmail')" [subtitle]="t('requireVerifiedEmail-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="requireVerifiedEmail" 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('syncUserSettings')" [subtitle]="t('syncUserSettings-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="syncUserSettings" 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('autoLogin')" [subtitle]="t('autoLogin-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="autoLogin" 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('disablePasswordAuthentication')" [subtitle]="t('disablePasswordAuthentication-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="disablePasswordAuthentication" 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')}}</h4>
|
||||
<div class="text-muted">{{t('defaults-requirement')}}</div>
|
||||
<ng-container>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('defaultAgeRating'); as formControl) {
|
||||
<app-setting-item [title]="t('defaultAgeRating')" [subtitle]="t('defaultAgeRating-tooltip')">
|
||||
<ng-template #view>
|
||||
<div>{{formControl.value | ageRating}}</div>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" formControlName="defaultAgeRating">
|
||||
<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('defaultIncludeUnknowns')" [subtitle]="t('defaultIncludeUnknowns-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="defaultIncludeUnknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (this.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>
|
||||
|
||||
</form>
|
||||
|
||||
</ng-container>
|
|
@ -0,0 +1,8 @@
|
|||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 5px;
|
||||
top: -42px;
|
||||
}
|
|
@ -0,0 +1,175 @@
|
|||
import {ChangeDetectorRef, Component, DestroyRef, effect, OnInit, signal} from '@angular/core';
|
||||
import {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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-open-idconnect',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SettingItemComponent,
|
||||
SettingSwitchComponent,
|
||||
AgeRatingPipe,
|
||||
LibrarySelectorComponent,
|
||||
RoleSelectorComponent
|
||||
],
|
||||
templateUrl: './manage-open-idconnect.component.html',
|
||||
styleUrl: './manage-open-idconnect.component.scss'
|
||||
})
|
||||
export class ManageOpenIDConnectComponent implements OnInit {
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
oidcSettings = signal<OidcConfig | undefined>(undefined);
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
ageRatings = signal<AgeRatingDto[]>([]);
|
||||
selectedLibraries = signal<number[]>([]);
|
||||
selectedRoles = signal<string[]>([]);
|
||||
|
||||
constructor(
|
||||
private settingsService: SettingsService,
|
||||
private cdRef: ChangeDetectorRef,
|
||||
private destroyRef: DestroyRef,
|
||||
private metadataService: MetadataService,
|
||||
) {
|
||||
}
|
||||
|
||||
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('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('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("defaultAgeRating", new FormControl(this.serverSettings.oidcConfig.defaultAgeRating, []));
|
||||
this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, []));
|
||||
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() {
|
||||
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.defaultAgeRating = parseInt(newSettings.oidcConfig.defaultAgeRating as unknown as string, 10) as AgeRating;
|
||||
newSettings.oidcConfig.defaultRoles = this.selectedRoles();
|
||||
newSettings.oidcConfig.defaultLibraries = this.selectedLibraries();
|
||||
|
||||
this.settingsService.updateServerSettings(newSettings).subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings.set(data.oidcConfig);
|
||||
this.cdRef.markForCheck();
|
||||
},
|
||||
error: error => {
|
||||
console.error(error);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if (uri.endsWith('/')) {
|
||||
uri = uri.substring(0, uri.length - 1);
|
||||
}
|
||||
|
||||
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}}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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,11 @@
|
|||
<tbody>
|
||||
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="-flex flex-row justify-content-center align-items-center">
|
||||
<app-owner-icon [owner]="member.owner" />
|
||||
</div>
|
||||
</td>
|
||||
<td id="username--{{idx}}">
|
||||
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
|
||||
@if (member.isPending) {
|
||||
|
|
|
@ -12,7 +12,7 @@ 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 {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common';
|
||||
import {TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.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 {UserOwner} from "../../_models/user";
|
||||
import {OwnerIconComponent} from "../../shared/_components/owner-icon/owner-icon.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, NgOptimizedImage, OwnerIconComponent]
|
||||
})
|
||||
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 UserOwner = UserOwner;
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -78,6 +78,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 + 'oidc/is-valid-authority', {authority});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,19 @@ import {NgModule} from '@angular/core';
|
|||
import {PreloadAllModules, RouterModule, Routes} from '@angular/router';
|
||||
import {AuthGuard} from './_guards/auth.guard';
|
||||
import {LibraryAccessGuard} from './_guards/library-access.guard';
|
||||
import {OidcResolver} from "./_resolvers/oidc.resolver";
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
canActivate: [AuthGuard],
|
||||
runGuardsAndResolvers: 'always',
|
||||
resolve: {
|
||||
// Require OIDC discovery to be loaded before launching the app
|
||||
// making sure we don't flash the login screen because we've made request before we could auto login
|
||||
// If no OIDC is set up, this will resolve after one request to the backend
|
||||
_: OidcResolver,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'settings',
|
||||
|
@ -103,6 +110,10 @@ const routes: Routes = [
|
|||
path: 'login',
|
||||
loadChildren: () => import('./_routes/registration.router.module').then(m => m.routes) // TODO: Refactor so we just use /registration/login going forward
|
||||
},
|
||||
{
|
||||
path: 'oidc',
|
||||
loadChildren: () => import('./_routes/oidc-routing.module').then(m => m.routes)
|
||||
},
|
||||
{path: 'libraries', pathMatch: 'full', redirectTo: 'home'},
|
||||
{path: '**', pathMatch: 'prefix', redirectTo: 'home'},
|
||||
{path: '**', pathMatch: 'full', redirectTo: 'home'},
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
DestroyRef, effect,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit
|
||||
|
@ -25,6 +25,7 @@ import {TranslocoService} from "@jsverse/transloco";
|
|||
import {VersionService} from "./_services/version.service";
|
||||
import {LicenseService} from "./_services/license.service";
|
||||
import {LocalizationService} from "./_services/localization.service";
|
||||
import {OidcEvents, OidcService} from "./_services/oidc.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
|
@ -51,6 +52,7 @@ export class AppComponent implements OnInit {
|
|||
private readonly document = inject(DOCUMENT);
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
|
||||
private readonly oidcService = inject(OidcService);
|
||||
private readonly licenseService = inject(LicenseService);
|
||||
private readonly localizationService = inject(LocalizationService);
|
||||
|
||||
|
@ -98,6 +100,25 @@ export class AppComponent implements OnInit {
|
|||
|
||||
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
|
||||
|
||||
// Update token, or login when one becomes available
|
||||
this.oidcService.events$.subscribe(event => {
|
||||
if (event.type !== OidcEvents.TokenRefreshed) return;
|
||||
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (user) {
|
||||
user.oidcToken = this.oidcService.token;
|
||||
return;
|
||||
}
|
||||
|
||||
this.accountService.loginByToken(this.oidcService.token).subscribe({
|
||||
next: () => {
|
||||
this.navService.handleLogin();
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
<ngx-extended-pdf-viewer
|
||||
#pdfViewer
|
||||
[src]="readerService.downloadPdf(this.chapterId)"
|
||||
[authorization]="'Bearer ' + user.token"
|
||||
[authorization]="'Bearer ' + (user.oidcToken ?? user.token)"
|
||||
height="100vh"
|
||||
[(page)]="currentPage"
|
||||
[textLayer]="true"
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
<ng-container *transloco="let t; prefix: 'oidc'">
|
||||
@if (showSplash()) {
|
||||
<app-splash-container>
|
||||
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
||||
<ng-container body>
|
||||
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
|
||||
</ng-container>
|
||||
</app-splash-container>
|
||||
}
|
||||
</ng-container>
|
|
@ -0,0 +1,3 @@
|
|||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import {ChangeDetectorRef, Component, OnInit, signal} from '@angular/core';
|
||||
import {SplashContainerComponent} from "../_components/splash-container/splash-container.component";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
import {Router} from "@angular/router";
|
||||
import {NavService} from "../../_services/nav.service";
|
||||
import {take} from "rxjs/operators";
|
||||
|
||||
@Component({
|
||||
selector: 'app-oidc-callback',
|
||||
imports: [
|
||||
SplashContainerComponent,
|
||||
TranslocoDirective
|
||||
],
|
||||
templateUrl: './oidc-callback.component.html',
|
||||
styleUrl: './oidc-callback.component.scss'
|
||||
})
|
||||
export class OidcCallbackComponent implements OnInit {
|
||||
|
||||
showSplash = signal(false);
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private router: Router,
|
||||
private navService: NavService,
|
||||
private readonly cdRef: ChangeDetectorRef,
|
||||
) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.router.navigateByUrl('/home');
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
// Show back to log in splash only after 1s, for a more seamless experience
|
||||
setTimeout(() => this.showSplash.set(true), 1000);
|
||||
}
|
||||
|
||||
goToLogin() {
|
||||
this.router.navigateByUrl('/login');
|
||||
}
|
||||
}
|
|
@ -1,32 +1,51 @@
|
|||
<ng-container *transloco="let t; read: 'login'">
|
||||
<ng-container *transloco="let t; prefix: 'login'">
|
||||
<app-splash-container>
|
||||
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
||||
<ng-container body>
|
||||
<ng-container *ngIf="isLoaded">
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
|
||||
<div class="card-text">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
|
||||
<input class="form-control custom-input" formControlName="username" id="username" autocomplete="username"
|
||||
type="text" autofocus [placeholder]="t('username')">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="password" class="form-label visually-hidden">{{t('password')}}</label>
|
||||
<input class="form-control custom-input" formControlName="password" name="password" autocomplete="current-password"
|
||||
id="password" type="password" [placeholder]="t('password')">
|
||||
</div>
|
||||
@if (isLoaded()) {
|
||||
@if (showPasswordLogin() && !firstTimeFlow()) {
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation">
|
||||
<div class="card-text">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
|
||||
<input class="form-control custom-input" formControlName="username" id="username" autocomplete="username"
|
||||
type="text" autofocus [placeholder]="t('username')">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 forgot-password">
|
||||
<a routerLink="/registration/reset-password">{{t('forgot-password')}}</a>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="password" class="form-label visually-hidden">{{t('password')}}</label>
|
||||
<input class="form-control custom-input" formControlName="password" name="password" autocomplete="current-password"
|
||||
id="password" type="password" [placeholder]="t('password')">
|
||||
</div>
|
||||
|
||||
<div class="sign-in">
|
||||
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting">{{t('submit')}}</button>
|
||||
<div class="mb-3 forgot-password">
|
||||
<a routerLink="/registration/reset-password">{{t('forgot-password')}}</a>
|
||||
</div>
|
||||
|
||||
<div class="sign-in">
|
||||
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting()">{{t('submit')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (oidcService.inUse()) {
|
||||
<button
|
||||
class="btn btn-outline-primary mt-2 d-flex align-items-center gap-2"
|
||||
(click)="oidcService.login()">
|
||||
<img
|
||||
ngSrc="assets/icons/open-id-connect-logo.svg"
|
||||
alt="OIDC"
|
||||
width="36"
|
||||
height="36"
|
||||
class="d-inline-block"
|
||||
style="object-fit: contain;"
|
||||
/>
|
||||
{{oidcService.settings()?.providerName || t('oidc')}}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
</app-splash-container>
|
||||
</ng-container>
|
||||
|
|
|
@ -46,3 +46,7 @@ a {
|
|||
font-family: var(--login-input-font-family);
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, computed,
|
||||
effect, inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AccountService } from '../../_services/account.service';
|
||||
import { MemberService } from '../../_services/member.service';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
import { NgIf } from '@angular/common';
|
||||
import {NgOptimizedImage} from '@angular/common';
|
||||
import { SplashContainerComponent } from '../_components/splash-container/splash-container.component';
|
||||
import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {OidcService} from "../../_services/oidc.service";
|
||||
|
||||
|
||||
@Component({
|
||||
|
@ -17,10 +26,21 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
|
|||
templateUrl: './user-login.component.html',
|
||||
styleUrls: ['./user-login.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective]
|
||||
imports: [SplashContainerComponent, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip, NgOptimizedImage]
|
||||
})
|
||||
export class UserLoginComponent implements OnInit {
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly memberService = inject(MemberService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly navService = inject(NavService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
protected readonly oidcService = inject(OidcService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
loginForm: FormGroup = new FormGroup({
|
||||
username: new FormControl('', [Validators.required]),
|
||||
password: new FormControl('', [Validators.required, Validators.maxLength(256), Validators.minLength(6), Validators.pattern("^.{6,256}$")])
|
||||
|
@ -29,19 +49,47 @@ export class UserLoginComponent implements OnInit {
|
|||
/**
|
||||
* If there are no admins on the server, this will enable the registration to kick in.
|
||||
*/
|
||||
firstTimeFlow: boolean = true;
|
||||
firstTimeFlow = signal(true);
|
||||
/**
|
||||
* Used for first time the page loads to ensure no flashing
|
||||
*/
|
||||
isLoaded: boolean = false;
|
||||
isSubmitting = false;
|
||||
isLoaded = signal(false);
|
||||
isSubmitting = signal(false);
|
||||
/**
|
||||
* undefined until query params are read
|
||||
*/
|
||||
skipAutoLogin = signal<boolean | undefined>(undefined);
|
||||
/**
|
||||
* Display the login form, regardless if the password authentication is disabled (admins can still log in)
|
||||
* Set from query
|
||||
*/
|
||||
forceShowPasswordLogin = signal(false);
|
||||
/**
|
||||
* Display the login form
|
||||
*/
|
||||
showPasswordLogin = computed(() => {
|
||||
const loaded = this.isLoaded();
|
||||
const config = this.oidcService.settings();
|
||||
const force = this.forceShowPasswordLogin();
|
||||
if (force) return true;
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
|
||||
private toastr: ToastrService, private navService: NavService,
|
||||
private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
return loaded && config && !config.disablePasswordAuthentication;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
|
||||
effect(() => {
|
||||
const skipAutoLogin = this.skipAutoLogin();
|
||||
const oidcConfig = this.oidcService.settings();
|
||||
if (!oidcConfig || skipAutoLogin === undefined) return;
|
||||
|
||||
if (oidcConfig.autoLogin && !skipAutoLogin) {
|
||||
this.oidcService.login()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
|
@ -55,22 +103,25 @@ export class UserLoginComponent implements OnInit {
|
|||
|
||||
|
||||
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
|
||||
this.firstTimeFlow = !adminExists;
|
||||
this.firstTimeFlow.set(!adminExists);
|
||||
|
||||
if (this.firstTimeFlow) {
|
||||
if (this.firstTimeFlow()) {
|
||||
this.router.navigateByUrl('registration/register');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.isLoaded.set(true);
|
||||
});
|
||||
|
||||
this.route.queryParamMap.subscribe(params => {
|
||||
const val = params.get('apiKey');
|
||||
if (val != null && val.length > 0) {
|
||||
this.login(val);
|
||||
return;
|
||||
}
|
||||
|
||||
this.skipAutoLogin.set(params.get('skipAutoLogin') === 'true')
|
||||
this.forceShowPasswordLogin.set(params.get('forceShowPassword') === 'true');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -79,28 +130,18 @@ export class UserLoginComponent implements OnInit {
|
|||
login(apiKey: string = '') {
|
||||
const model = this.loginForm.getRawValue();
|
||||
model.apiKey = apiKey;
|
||||
this.isSubmitting = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.accountService.login(model).subscribe(() => {
|
||||
this.loginForm.reset();
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.isSubmitting.set(true);
|
||||
this.accountService.login(model).subscribe({
|
||||
next: () => {
|
||||
this.loginForm.reset();
|
||||
this.navService.handleLogin()
|
||||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
|
||||
if (pageResume && pageResume !== '/login') {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl(pageResume);
|
||||
} else {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl('/home');
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastr.error(err.error);
|
||||
this.isSubmitting.set(false);
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
this.cdRef.markForCheck();
|
||||
}, err => {
|
||||
this.toastr.error(err.error);
|
||||
this.isSubmitting = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.OpenIDConnect; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.OpenIDConnect) {
|
||||
<div class="col-xxl-6 col-12">
|
||||
<app-manage-open-idconnect></app-manage-open-idconnect>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.Email; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.Email) {
|
||||
<div class="col-xxl-6 col-12">
|
||||
|
|
|
@ -55,6 +55,7 @@ import {
|
|||
import {
|
||||
ManageReadingProfilesComponent
|
||||
} from "../../../user-settings/manage-reading-profiles/manage-reading-profiles.component";
|
||||
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
|
@ -91,7 +92,8 @@ import {
|
|||
EmailHistoryComponent,
|
||||
ScrobblingHoldsComponent,
|
||||
ManageMetadataSettingsComponent,
|
||||
ManageReadingProfilesComponent
|
||||
ManageReadingProfilesComponent,
|
||||
ManageOpenIDConnectComponent
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss',
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
@switch (owner()) {
|
||||
@case (UserOwner.OpenIdConnect) {
|
||||
<img [width]="size()" [height]="size()" ngSrc="assets/icons/open-id-connect-logo.svg" alt="open-id-connect-logo">
|
||||
}
|
||||
@case (UserOwner.Native) {
|
||||
<!-- TODO: SVG for scaling -->
|
||||
<img ngSrc="assets/icons/favicon-16x16.png" [width]="size()" [height]="size()" alt="kavita-logo">
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
import {Component, input} from '@angular/core';
|
||||
import {NgOptimizedImage} from "@angular/common";
|
||||
import {UserOwner} from "../../../_models/user";
|
||||
|
||||
@Component({
|
||||
selector: 'app-owner-icon',
|
||||
imports: [
|
||||
NgOptimizedImage
|
||||
],
|
||||
templateUrl: './owner-icon.component.html',
|
||||
styleUrl: './owner-icon.component.scss'
|
||||
})
|
||||
export class OwnerIconComponent {
|
||||
|
||||
owner = input.required<UserOwner>();
|
||||
size = input<number>(16);
|
||||
|
||||
protected readonly UserOwner = UserOwner;
|
||||
}
|
|
@ -21,6 +21,7 @@ export enum SettingsTabId {
|
|||
|
||||
// Admin
|
||||
General = 'admin-general',
|
||||
OpenIDConnect = 'admin-oidc',
|
||||
Email = 'admin-email',
|
||||
Media = 'admin-media',
|
||||
Users = 'admin-users',
|
||||
|
@ -124,6 +125,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||
title: 'server-section-title',
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.General, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
|
||||
|
|
1
UI/Web/src/assets/icons/open-id-connect-logo.svg
Normal file
1
UI/Web/src/assets/icons/open-id-connect-logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0"?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg height="512px" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" width="512px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="_x32_39-openid"><g><path d="M234.849,419v6.623c-79.268-9.958-139.334-53.393-139.334-105.757 c0-39.313,33.873-73.595,84.485-92.511L178.023,180C88.892,202.497,26.001,256.607,26.001,319.866 c0,76.288,90.871,139.128,208.95,149.705l0.018-0.009V419H234.849z" style="fill:#B2B2B2;"/><polygon points="304.772,436.713 304.67,436.713 304.67,221.667 304.67,213.667 304.67,42.429 234.849,78.25 234.849,221.667 234.969,221.667 234.969,469.563 " style="fill:#F7931E;"/><path d="M485.999,291.938l-9.446-100.114l-35.938,20.331C415.087,196.649,382.5,177.5,340,177.261 l0.002,36.406v7.498c3.502,0.968,6.923,2.024,10.301,3.125c14.145,4.611,27.176,10.352,38.666,17.128l-37.786,21.254 L485.999,291.938z" style="fill:#B2B2B2;"/></g></g><g id="Layer_1"/></svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -5,7 +5,55 @@
|
|||
"password": "{{common.password}}",
|
||||
"password-validation": "{{validation.password-validation}}",
|
||||
"forgot-password": "Forgot Password?",
|
||||
"submit": "Sign in"
|
||||
"submit": "Sign in",
|
||||
"oidc": "OpenID Connect"
|
||||
},
|
||||
|
||||
"oidc": {
|
||||
"title": "OpenID Connect Callback",
|
||||
"login": "Back to login screen",
|
||||
"error-loading-info": "An error occurred loading OpenID Connect info, contact your administrator",
|
||||
"timeout": "OIDC resolution has timed out or an error has occurred",
|
||||
"settings": {
|
||||
"save": "{{common.save}}",
|
||||
"notice": "Notice",
|
||||
"restart-required": "Changing Authority, or Client ID requires a manual restart of Kavita to take effect.",
|
||||
"provider": "Provider",
|
||||
"behaviour": "Behaviour",
|
||||
"field-required": "{{name}} is required when {{other}} is set",
|
||||
"invalidUri": "The provider URL is not valid",
|
||||
"manual-save": "Changing provider settings requires a manual save",
|
||||
|
||||
"authority": "Authority",
|
||||
"authority-tooltip": "The URL to your OpenID Connect provider",
|
||||
"clientId": "Client ID",
|
||||
"clientId-tooltip": "The ClientID set in your OIDC provider, can be anything",
|
||||
"provisionAccounts": "Provision accounts",
|
||||
"provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account",
|
||||
"requireVerifiedEmail": "Require verified emails",
|
||||
"requireVerifiedEmail-tooltip": "Requires emails to be verified when creation an account or matching with existing ones. A newly created account with a verified email, will be auto verified on Kavita's side",
|
||||
"syncUserSettings": "Sync user settings with OIDC roles",
|
||||
"syncUserSettings-tooltip": "Users created from OIDC will be fully managed (Roles, Library Access, Age Rating) by the OIDC. If this is disabled, users will be unable to access any content after their account creation. Read the documentation for more information.",
|
||||
"autoLogin": "Auto login",
|
||||
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen",
|
||||
"disablePasswordAuthentication": "Disable password authentication",
|
||||
"disablePasswordAuthentication-tooltip": "Users with the admin role can bypass this restriction",
|
||||
"providerName": "Provider name",
|
||||
"providerName-tooltip": "Name show on the login screen",
|
||||
|
||||
"defaults": "Defaults",
|
||||
"defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off",
|
||||
"defaultIncludeUnknowns": "Include unknowns",
|
||||
"defaultIncludeUnknowns-tooltip": "Include unknown age ratings",
|
||||
"defaultAgeRating": "Age rating",
|
||||
"defaultAgeRating-tooltip": "Maximum age rating shown to new users",
|
||||
"no-restriction": "{{restriction-selector.no-restriction}}"
|
||||
}
|
||||
},
|
||||
|
||||
"creation-source-pipe": {
|
||||
"native": "Native",
|
||||
"oidc": "OpenID Connect"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
|
@ -28,7 +76,12 @@
|
|||
"cancel": "{{common.cancel}}",
|
||||
"saving": "Saving…",
|
||||
"update": "Update",
|
||||
"account-detail-title": "Account Details"
|
||||
"account-detail-title": "Account Details",
|
||||
"notice": "Warning!",
|
||||
"out-of-sync": "This user was created via OIDC, if the SynUsers setting is turned on changes made may be lost",
|
||||
"oidc-managed": "This user is managed via OIDC, contact your OIDC administrator if they require changes.",
|
||||
"owner": "Ownership",
|
||||
"owner-tooltip": "Native users will never be synced with OIDC"
|
||||
},
|
||||
|
||||
"user-scrobble-history": {
|
||||
|
@ -1698,6 +1751,7 @@
|
|||
"import-section-title": "Import",
|
||||
"kavitaplus-section-title": "Kavita+",
|
||||
"admin-general": "General",
|
||||
"admin-oidc": "OpenID Connect",
|
||||
"admin-users": "Users",
|
||||
"admin-libraries": "Libraries",
|
||||
"admin-media": "Media",
|
||||
|
@ -2421,7 +2475,16 @@
|
|||
"invalid-password-reset-url": "Invalid reset password url",
|
||||
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
|
||||
"theme-manual-upload": "There was an issue creating Theme from manual upload",
|
||||
"theme-already-in-use": "Theme already exists by that name"
|
||||
"theme-already-in-use": "Theme already exists by that name",
|
||||
"oidc": {
|
||||
"missing-external-id": "OpenID Connect provider did not return a valid identifier",
|
||||
"missing-email": "OpenID Connect provider did not return a valid email",
|
||||
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
|
||||
"no-account": "No matching account found",
|
||||
"disabled-account": "This account is disabled, please contact an administrator",
|
||||
"creating-user": "Failed to create a new user, please contact an administrator",
|
||||
"role-not-assigned": "You do not have the required roles assigned to access this application"
|
||||
}
|
||||
},
|
||||
|
||||
"metadata-builder": {
|
||||
|
|
|
@ -20,6 +20,7 @@ import {distinctUntilChanged} from "rxjs/operators";
|
|||
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
|
||||
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
|
||||
import {HttpLoader} from "./httpLoader";
|
||||
import {provideOAuthClient} from "angular-oauth2-oidc";
|
||||
|
||||
const disableAnimations = !('animate' in document.documentElement);
|
||||
|
||||
|
@ -146,6 +147,7 @@ bootstrapApplication(AppComponent, {
|
|||
useFactory: getBaseHref,
|
||||
deps: [PlatformLocation]
|
||||
},
|
||||
provideOAuthClient(),
|
||||
provideHttpClient(withInterceptorsFromDi())
|
||||
]
|
||||
} as ApplicationConfig)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue