Make a proper disction betwen who owns the account, preperation for actual sync
This commit is contained in:
parent
dc91696769
commit
9fb29dec20
25 changed files with 4021 additions and 57 deletions
|
|
@ -532,6 +532,7 @@ public class AccountController : BaseApiController
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dto"></param>
|
/// <param name="dto"></param>
|
||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
|
/// <remarks>OIDC managed users cannot be edited if SyncUsers is enabled</remarks>
|
||||||
[Authorize(Policy = "RequireAdminRole")]
|
[Authorize(Policy = "RequireAdminRole")]
|
||||||
[HttpPost("update")]
|
[HttpPost("update")]
|
||||||
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
|
public async Task<ActionResult> UpdateAccount(UpdateUserDto dto)
|
||||||
|
|
@ -544,6 +545,21 @@ public class AccountController : BaseApiController
|
||||||
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
|
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId, AppUserIncludes.SideNavStreams);
|
||||||
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
|
if (user == null) return BadRequest(await _localizationService.Translate(User.GetUserId(), "no-user"));
|
||||||
|
|
||||||
|
// Disallowed editing users synced via 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)
|
||||||
|
{
|
||||||
|
user.Owner = dto.Owner;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if username is changing
|
// Check if username is changing
|
||||||
if (!user.UserName!.Equals(dto.Username))
|
if (!user.UserName!.Equals(dto.Username))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
namespace API.DTOs.Account;
|
namespace API.DTOs.Account;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
@ -25,4 +26,5 @@ public sealed record UpdateUserDto
|
||||||
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
|
public AgeRestrictionDto AgeRestriction { get; init; } = default!;
|
||||||
/// <inheritdoc cref="API.Entities.AppUser.Email"/>
|
/// <inheritdoc cref="API.Entities.AppUser.Email"/>
|
||||||
public string? Email { get; set; } = default!;
|
public string? Email { get; set; } = default!;
|
||||||
|
public AppUserOwner Owner { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
@ -24,4 +25,5 @@ public sealed record MemberDto
|
||||||
public DateTime LastActiveUtc { get; init; }
|
public DateTime LastActiveUtc { get; init; }
|
||||||
public IEnumerable<LibraryDto>? Libraries { get; init; }
|
public IEnumerable<LibraryDto>? Libraries { get; init; }
|
||||||
public IEnumerable<string>? Roles { get; init; }
|
public IEnumerable<string>? Roles { get; init; }
|
||||||
|
public AppUserOwner Owner { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,7 @@ public record OidcConfigDto: OidcPublicConfigDto
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
|
/// Overwrite Kavita roles, libraries and age rating with OpenIDConnect provides roles on log in.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool ProvisionUserSettings { get; set; }
|
public bool SyncUserSettings { get; set; }
|
||||||
/// <summary>
|
|
||||||
/// Requires roles to be configured in OIDC
|
|
||||||
/// </summary>
|
|
||||||
public bool RequireRoles { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if the <see cref="OidcPublicConfigDto.Authority"/> has been set
|
/// Returns true if the <see cref="OidcPublicConfigDto.Authority"/> has been set
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
|
|
||||||
using System;
|
|
||||||
using API.DTOs.Account;
|
using API.DTOs.Account;
|
||||||
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
|
|
||||||
namespace API.DTOs;
|
namespace API.DTOs;
|
||||||
#nullable enable
|
#nullable enable
|
||||||
|
|
@ -15,4 +16,6 @@ public sealed record UserDto
|
||||||
public UserPreferencesDto? Preferences { get; set; }
|
public UserPreferencesDto? Preferences { get; set; }
|
||||||
public AgeRestrictionDto? AgeRestriction { get; init; }
|
public AgeRestrictionDto? AgeRestriction { get; init; }
|
||||||
public string KavitaVersion { get; set; }
|
public string KavitaVersion { get; set; }
|
||||||
|
/// <inheritdoc cref="AppUser.Owner"/>
|
||||||
|
public AppUserOwner Owner { get; init; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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
39
API/Data/Migrations/20250701154425_AppUserOwner.cs
Normal file
39
API/Data/Migrations/20250701154425_AppUserOwner.cs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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<string>(
|
||||||
|
name: "ExternalId",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Owner",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ExternalId",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Owner",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -119,6 +119,9 @@ namespace API.Data.Migrations
|
||||||
.HasMaxLength(256)
|
.HasMaxLength(256)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Owner")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("PasswordHash")
|
b.Property<string>("PasswordHash")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|
@ -3637,7 +3640,8 @@ namespace API.Data.Migrations
|
||||||
|
|
||||||
b.Navigation("TableOfContents");
|
b.Navigation("TableOfContents");
|
||||||
|
|
||||||
b.Navigation("UserPreferences");
|
b.Navigation("UserPreferences")
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
b.Navigation("UserRoles");
|
b.Navigation("UserRoles");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -800,6 +800,7 @@ public class UserRepository : IUserRepository
|
||||||
LastActiveUtc = u.LastActiveUtc,
|
LastActiveUtc = u.LastActiveUtc,
|
||||||
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
Roles = u.UserRoles.Select(r => r.Role.Name).ToList(),
|
||||||
IsPending = !u.EmailConfirmed,
|
IsPending = !u.EmailConfirmed,
|
||||||
|
Owner = u.Owner,
|
||||||
AgeRestriction = new AgeRestrictionDto()
|
AgeRestriction = new AgeRestrictionDto()
|
||||||
{
|
{
|
||||||
AgeRating = u.AgeRestriction,
|
AgeRating = u.AgeRestriction,
|
||||||
|
|
@ -811,7 +812,7 @@ public class UserRepository : IUserRepository
|
||||||
Type = l.Type,
|
Type = l.Type,
|
||||||
LastScanned = l.LastScanned,
|
LastScanned = l.LastScanned,
|
||||||
Folders = l.Folders.Select(x => x.Path).ToList()
|
Folders = l.Folders.Select(x => x.Path).ToList()
|
||||||
}).ToList()
|
}).ToList(),
|
||||||
})
|
})
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
#nullable enable
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using API.DTOs.Settings;
|
||||||
using API.Entities.Enums;
|
using API.Entities.Enums;
|
||||||
using API.Entities.Interfaces;
|
using API.Entities.Interfaces;
|
||||||
using API.Entities.Scrobble;
|
using API.Entities.Scrobble;
|
||||||
|
|
@ -93,6 +95,10 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
||||||
/// The sub returned the by OIDC provider
|
/// The sub returned the by OIDC provider
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? ExternalId { get; set; }
|
public string? ExternalId { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Describes how the account was created
|
||||||
|
/// </summary>
|
||||||
|
public AppUserOwner Owner { get; set; } = AppUserOwner.Native;
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||||
using API.Constants;
|
using API.Constants;
|
||||||
using API.Data;
|
using API.Data;
|
||||||
using API.Entities;
|
using API.Entities;
|
||||||
|
using API.Entities.Enums;
|
||||||
using API.Helpers;
|
using API.Helpers;
|
||||||
using API.Services;
|
using API.Services;
|
||||||
using Kavita.Common;
|
using Kavita.Common;
|
||||||
|
|
@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
|
using MessageReceivedContext = Microsoft.AspNetCore.Authentication.JwtBearer.MessageReceivedContext;
|
||||||
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext;
|
using TokenValidatedContext = Microsoft.AspNetCore.Authentication.JwtBearer.TokenValidatedContext;
|
||||||
|
|
@ -158,9 +160,10 @@ public static class IdentityServiceExtensions
|
||||||
|
|
||||||
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
|
private static async Task OidcClaimsPrincipalConverter(TokenValidatedContext ctx)
|
||||||
{
|
{
|
||||||
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
|
|
||||||
if (ctx.Principal == null) return;
|
if (ctx.Principal == null) return;
|
||||||
|
|
||||||
|
var oidcService = ctx.HttpContext.RequestServices.GetRequiredService<IOidcService>();
|
||||||
|
var unitOfWork = ctx.HttpContext.RequestServices.GetRequiredService<IUnitOfWork>();
|
||||||
var user = await oidcService.LoginOrCreate(ctx.Principal);
|
var user = await oidcService.LoginOrCreate(ctx.Principal);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
{
|
{
|
||||||
|
|
@ -169,17 +172,25 @@ public static class IdentityServiceExtensions
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the following claims like Kavita expects them
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||||
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
|
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
|
||||||
new(ClaimTypes.Name, user.UserName ?? string.Empty)
|
new(ClaimTypes.Name, user.UserName ?? string.Empty),
|
||||||
};
|
};
|
||||||
|
|
||||||
var userManager = ctx.HttpContext.RequestServices.GetRequiredService<UserManager<AppUser>>();
|
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
|
||||||
var roles = await userManager.GetRolesAsync(user);
|
if (user.Owner != AppUserOwner.OpenIdConnect || !settings.OidcConfig.SyncUserSettings)
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
{
|
||||||
claims.AddRange(ctx.Principal.Claims);
|
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 identity = new ClaimsIdentity(claims, ctx.Scheme.Name);
|
||||||
var principal = new ClaimsPrincipal(identity);
|
var principal = new ClaimsPrincipal(identity);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"generate-token": "There was an issue generating a confirmation email token. See logs",
|
"generate-token": "There was an issue generating a confirmation email token. See logs",
|
||||||
"age-restriction-update": "There was an error updating the age restriction",
|
"age-restriction-update": "There was an error updating the age restriction",
|
||||||
"no-user": "User does not exist",
|
"no-user": "User does not exist",
|
||||||
|
"oidc-managed": "This user is managed by OIDC, cannot edit",
|
||||||
"username-taken": "Username already taken",
|
"username-taken": "Username already taken",
|
||||||
"email-taken": "Email already in use",
|
"email-taken": "Email already in use",
|
||||||
"user-already-confirmed": "User is already confirmed",
|
"user-already-confirmed": "User is already confirmed",
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ public interface IOidcService
|
||||||
/// <exception cref="KavitaException">if any requirements aren't met</exception>
|
/// <exception cref="KavitaException">if any requirements aren't met</exception>
|
||||||
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
|
Task<AppUser?> LoginOrCreate(ClaimsPrincipal principal);
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates roles, library access and age rating. Does not assign admin role, or to admin roles
|
/// Updates roles, library access and age rating. Will not modify the default admin
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="settings"></param>
|
/// <param name="settings"></param>
|
||||||
/// <param name="claimsPrincipal"></param>
|
/// <param name="claimsPrincipal"></param>
|
||||||
|
|
@ -68,6 +68,9 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||||
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
|
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
|
||||||
throw new KavitaException("errors.oidc.email-not-verified");
|
throw new KavitaException("errors.oidc.email-not-verified");
|
||||||
|
|
||||||
|
if (settings.SyncUserSettings && principal.GetAccessRoles().Count == 0)
|
||||||
|
throw new KavitaException("errors.oidc.role-not-assigned");
|
||||||
|
|
||||||
|
|
||||||
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams)
|
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams)
|
||||||
?? await NewUserFromOpenIdConnect(settings, principal);
|
?? await NewUserFromOpenIdConnect(settings, principal);
|
||||||
|
|
@ -126,6 +129,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||||
throw new KavitaException("errors.oidc.creating-user");
|
throw new KavitaException("errors.oidc.creating-user");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user.Owner = AppUserOwner.OpenIdConnect;
|
||||||
AddDefaultStreamsToUser(user, mapper);
|
AddDefaultStreamsToUser(user, mapper);
|
||||||
await AddDefaultReadingProfileToUser(user);
|
await AddDefaultReadingProfileToUser(user);
|
||||||
|
|
||||||
|
|
@ -137,6 +141,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||||
}
|
}
|
||||||
|
|
||||||
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
|
await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole);
|
||||||
|
await userManager.AddToRoleAsync(user, PolicyConstants.PlebRole);
|
||||||
|
|
||||||
await unitOfWork.CommitAsync();
|
await unitOfWork.CommitAsync();
|
||||||
return user;
|
return user;
|
||||||
|
|
@ -144,11 +149,10 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||||
|
|
||||||
public async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
public async Task SyncUserSettings(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||||
{
|
{
|
||||||
if (!settings.ProvisionUserSettings) return;
|
if (!settings.SyncUserSettings) return;
|
||||||
|
|
||||||
var userRoles = await userManager.GetRolesAsync(user);
|
|
||||||
if (userRoles.Contains(PolicyConstants.AdminRole)) return;
|
|
||||||
|
|
||||||
|
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
|
||||||
|
if (defaultAdminUser.Id == user.Id) return;
|
||||||
|
|
||||||
await SyncRoles(claimsPrincipal, user);
|
await SyncRoles(claimsPrincipal, user);
|
||||||
await SyncLibraries(claimsPrincipal, user);
|
await SyncLibraries(claimsPrincipal, user);
|
||||||
|
|
@ -161,29 +165,24 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
|
||||||
|
|
||||||
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
|
private async Task SyncRoles(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||||
{
|
{
|
||||||
var roles = claimsPrincipal.FindAll(ClaimTypes.Role)
|
var roles = claimsPrincipal.GetAccessRoles();
|
||||||
.Select(r => r.Value)
|
|
||||||
.Where(r => PolicyConstants.ValidRoles.Contains(r))
|
|
||||||
.Where(r => r != PolicyConstants.AdminRole)
|
|
||||||
.ToList();
|
|
||||||
if (roles.Count == 0) return;
|
|
||||||
|
|
||||||
var errors = await accountService.UpdateRolesForUser(user, roles);
|
var errors = await accountService.UpdateRolesForUser(user, roles);
|
||||||
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
|
if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
|
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)
|
||||||
{
|
{
|
||||||
|
var hasAdminRole = await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
|
||||||
|
|
||||||
var libraryAccess = claimsPrincipal
|
var libraryAccess = claimsPrincipal
|
||||||
.FindAll(ClaimTypes.Role)
|
.FindAll(ClaimTypes.Role)
|
||||||
.Where(r => r.Value.StartsWith(LibraryAccessPrefix))
|
.Where(r => r.Value.StartsWith(LibraryAccessPrefix))
|
||||||
.Select(r => r.Value.TrimPrefix(LibraryAccessPrefix))
|
.Select(r => r.Value.TrimPrefix(LibraryAccessPrefix))
|
||||||
.ToList();
|
.ToList();
|
||||||
if (libraryAccess.Count == 0) return;
|
if (libraryAccess.Count == 0 && !hasAdminRole) return;
|
||||||
|
|
||||||
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
var allLibraries = (await unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList();
|
||||||
var librariesIds = allLibraries.Where(l => libraryAccess.Contains(l.Name)).Select(l => l.Id).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);
|
await accountService.UpdateLibrariesForUser(user, librariesIds, hasAdminRole);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import {AgeRestriction} from '../metadata/age-restriction';
|
import {AgeRestriction} from '../metadata/age-restriction';
|
||||||
import {Library} from '../library/library';
|
import {Library} from '../library/library';
|
||||||
|
import {UserOwner} from "../user";
|
||||||
|
|
||||||
export interface Member {
|
export interface Member {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -13,4 +14,5 @@ export interface Member {
|
||||||
libraries: Library[];
|
libraries: Library[];
|
||||||
ageRestriction: AgeRestriction;
|
ageRestriction: AgeRestriction;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
|
owner: UserOwner;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,12 @@ export interface User {
|
||||||
ageRestriction: AgeRestriction;
|
ageRestriction: AgeRestriction;
|
||||||
hasRunScrobbleEventGeneration: boolean;
|
hasRunScrobbleEventGeneration: boolean;
|
||||||
scrobbleEventGenerationRan: string; // datetime
|
scrobbleEventGenerationRan: string; // datetime
|
||||||
|
owner: UserOwner,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum UserOwner {
|
||||||
|
Native = 0,
|
||||||
|
OpenIdConnect = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserOwners: UserOwner[] = [UserOwner.Native, UserOwner.OpenIdConnect];
|
||||||
|
|
|
||||||
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: 'creationSourcePipe'
|
||||||
|
})
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ export interface OidcConfig {
|
||||||
clientId: string;
|
clientId: string;
|
||||||
provisionAccounts: boolean;
|
provisionAccounts: boolean;
|
||||||
requireVerifiedEmail: boolean;
|
requireVerifiedEmail: boolean;
|
||||||
provisionUserSettings: boolean;
|
syncUserSettings: boolean;
|
||||||
autoLogin: boolean;
|
autoLogin: boolean;
|
||||||
disablePasswordAuthentication: boolean;
|
disablePasswordAuthentication: boolean;
|
||||||
providerName: string;
|
providerName: string;
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,26 @@
|
||||||
<ng-container *transloco="let t; read: 'edit-user'">
|
<ng-container *transloco="let t; prefix: 'edit-user'">
|
||||||
<div class="modal-container">
|
<div class="modal-container">
|
||||||
<div class="modal-header">
|
<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 type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||||
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-body scrollable-modal">
|
<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">
|
<form [formGroup]="userForm">
|
||||||
<h4>{{t('account-detail-title')}}</h4>
|
<h4>{{t('account-detail-title')}}</h4>
|
||||||
<div class="row g-0 mb-2">
|
<div class="row g-0 mb-2">
|
||||||
|
|
@ -59,21 +72,41 @@
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- TODO: Change move, idk, it's bad now -->
|
||||||
|
<div class="col-md-6 col-sm-12">
|
||||||
|
@if (userForm.get('owner'); as formControl) {
|
||||||
|
<app-setting-item [title]="t('owner')" [subtitle]="t('owner-tooltip')">
|
||||||
|
<ng-template #view>
|
||||||
|
<div>{{member().owner | UserOwnerPipe}}</div>
|
||||||
|
</ng-template>
|
||||||
|
<ng-template #edit>
|
||||||
|
<select class="form-select" id="creationSource" formControlName="creationSource">
|
||||||
|
@for (source of UserOwners; track source) {
|
||||||
|
<option [value]="source">{{source | UserOwnerPipe}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</ng-template>
|
||||||
|
</app-setting-item>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 mb-3">
|
<div class="row g-0 mb-3">
|
||||||
<div class="col-md-12">
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 mb-3">
|
<div class="row g-0 mb-3">
|
||||||
<div class="col-md-6 pe-4">
|
<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>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -83,7 +116,7 @@
|
||||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||||
{{t('cancel')}}
|
{{t('cancel')}}
|
||||||
</button>
|
</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) {
|
@if (isSaving) {
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,14 @@
|
||||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
DestroyRef, effect,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
Input, model,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||||
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||||
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
|
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
|
||||||
|
|
@ -9,11 +19,15 @@ import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
|
||||||
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
|
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
|
||||||
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
|
import {LibrarySelectorComponent} from '../library-selector/library-selector.component';
|
||||||
import {RoleSelectorComponent} from '../role-selector/role-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 {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 {map} from "rxjs/operators";
|
||||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
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";
|
||||||
|
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||||
|
|
||||||
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
|
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
|
||||||
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
@ -22,7 +36,7 @@ const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
selector: 'app-edit-user',
|
selector: 'app-edit-user',
|
||||||
templateUrl: './edit-user.component.html',
|
templateUrl: './edit-user.component.html',
|
||||||
styleUrls: ['./edit-user.component.scss'],
|
styleUrls: ['./edit-user.component.scss'],
|
||||||
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe],
|
imports: [ReactiveFormsModule, RoleSelectorComponent, LibrarySelectorComponent, RestrictionSelectorComponent, SentenceCasePipe, TranslocoDirective, AsyncPipe, UserOwnerPipe, SettingItemComponent],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
})
|
})
|
||||||
export class EditUserComponent implements OnInit {
|
export class EditUserComponent implements OnInit {
|
||||||
|
|
@ -32,7 +46,16 @@ export class EditUserComponent implements OnInit {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
protected readonly modal = inject(NgbActiveModal);
|
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> = [];
|
selectedRoles: Array<string> = [];
|
||||||
selectedLibraries: Array<number> = [];
|
selectedLibraries: Array<number> = [];
|
||||||
|
|
@ -52,18 +75,31 @@ export class EditUserComponent implements OnInit {
|
||||||
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required]));
|
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('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
|
||||||
|
this.userForm.addControl('creationSource', new FormControl(this.member().owner, [Validators.required]));
|
||||||
|
|
||||||
|
// TODO: Rework, bad hack
|
||||||
|
// Work around isLocked so we're able to downgrade users
|
||||||
|
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(
|
this.isEmailInvalid$ = this.userForm.get('email')!.valueChanges.pipe(
|
||||||
startWith(this.member.email),
|
startWith(this.member().email),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
debounceTime(10),
|
debounceTime(10),
|
||||||
map(value => !EmailRegex.test(value)),
|
map(value => !EmailRegex.test(value)),
|
||||||
takeUntilDestroyed(this.destroyRef)
|
takeUntilDestroyed(this.destroyRef)
|
||||||
);
|
);
|
||||||
|
|
||||||
this.selectedRestriction = this.member.ageRestriction;
|
this.selectedRestriction = this.member().ageRestriction;
|
||||||
this.cdRef.markForCheck();
|
this.cdRef.markForCheck();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,14 +124,18 @@ export class EditUserComponent implements OnInit {
|
||||||
|
|
||||||
save() {
|
save() {
|
||||||
const model = this.userForm.getRawValue();
|
const model = this.userForm.getRawValue();
|
||||||
model.userId = this.member.id;
|
model.userId = this.member().id;
|
||||||
model.roles = this.selectedRoles;
|
model.roles = this.selectedRoles;
|
||||||
model.libraries = this.selectedLibraries;
|
model.libraries = this.selectedLibraries;
|
||||||
model.ageRestriction = this.selectedRestriction;
|
model.ageRestriction = this.selectedRestriction;
|
||||||
|
model.owner = parseInt(model.owner, 10) as UserOwner;
|
||||||
|
|
||||||
|
|
||||||
this.accountService.update(model).subscribe(() => {
|
this.accountService.update(model).subscribe(() => {
|
||||||
this.modal.close(true);
|
this.modal.close(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly UserOwner = UserOwner;
|
||||||
|
protected readonly UserOwners = UserOwners;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,11 +104,11 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-0 mt-4 mb-4">
|
<div class="row g-0 mt-4 mb-4">
|
||||||
@if(settingsForm.get('provisionUserSettings'); as formControl) {
|
@if(settingsForm.get('syncUserSettings'); as formControl) {
|
||||||
<app-setting-switch [title]="t('provisionUserSettings')" [subtitle]="t('provisionUserSettings-tooltip')">
|
<app-setting-switch [title]="t('syncUserSettings')" [subtitle]="t('syncUserSettings-tooltip')">
|
||||||
<ng-template #switch>
|
<ng-template #switch>
|
||||||
<div class="form-check form-switch float-end">
|
<div class="form-check form-switch float-end">
|
||||||
<input id="provisionUserSettings" type="checkbox" class="form-check-input" formControlName="provisionUserSettings">
|
<input id="syncUserSettings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</app-setting-switch>
|
</app-setting-switch>
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export class ManageOpenIDConnectComponent implements OnInit {
|
||||||
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
|
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
|
||||||
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
|
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
|
||||||
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, []));
|
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, []));
|
||||||
this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, []));
|
this.settingsForm.addControl('syncUserSettings', new FormControl(this.oidcSettings.syncUserSettings, []));
|
||||||
this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, []));
|
this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, []));
|
||||||
this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, []));
|
this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, []));
|
||||||
this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, []));
|
this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, []));
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th scope="col"></th>
|
||||||
<th scope="col">{{t('name-header')}}</th>
|
<th scope="col">{{t('name-header')}}</th>
|
||||||
<th scope="col">{{t('last-active-header')}}</th>
|
<th scope="col">{{t('last-active-header')}}</th>
|
||||||
<th scope="col">{{t('sharing-header')}}</th>
|
<th scope="col">{{t('sharing-header')}}</th>
|
||||||
|
|
@ -20,6 +21,18 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
|
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
|
||||||
<tr>
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="-flex flex-row justify-content-center align-items-center">
|
||||||
|
@switch (member.owner) {
|
||||||
|
@case (UserOwner.OpenIdConnect) {
|
||||||
|
<img width="16" height="16" ngSrc="assets/icons/open-id-connect-logo.svg" alt="open-id-connect-logo">
|
||||||
|
}
|
||||||
|
@case (UserOwner.Native) {
|
||||||
|
<img ngSrc="assets/icons/favicon-16x16.png" height="16" width="16" alt="kavita-logo">
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td id="username--{{idx}}">
|
<td id="username--{{idx}}">
|
||||||
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
|
<span class="member-name" id="member-name--{{idx}}" [ngClass]="{'highlight': member.username === loggedInUsername}">{{member.username | titlecase}}</span>
|
||||||
@if (member.isPending) {
|
@if (member.isPending) {
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {InviteUserComponent} from '../invite-user/invite-user.component';
|
||||||
import {EditUserComponent} from '../edit-user/edit-user.component';
|
import {EditUserComponent} from '../edit-user/edit-user.component';
|
||||||
import {Router} from '@angular/router';
|
import {Router} from '@angular/router';
|
||||||
import {TagBadgeComponent} from '../../shared/tag-badge/tag-badge.component';
|
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 {TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||||
|
|
@ -23,6 +23,9 @@ import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||||
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
|
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
|
||||||
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||||
|
import {SettingsService} from "../settings.service";
|
||||||
|
import {ServerSettings} from "../_models/server-settings";
|
||||||
|
import {UserOwner} from "../../_models/user";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-manage-users',
|
selector: 'app-manage-users',
|
||||||
|
|
@ -31,7 +34,7 @@ import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
|
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
|
||||||
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
|
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
|
||||||
RoleLocalizedPipe]
|
RoleLocalizedPipe, NgOptimizedImage]
|
||||||
})
|
})
|
||||||
export class ManageUsersComponent implements OnInit {
|
export class ManageUsersComponent implements OnInit {
|
||||||
|
|
||||||
|
|
@ -41,6 +44,7 @@ export class ManageUsersComponent implements OnInit {
|
||||||
private readonly cdRef = inject(ChangeDetectorRef);
|
private readonly cdRef = inject(ChangeDetectorRef);
|
||||||
private readonly memberService = inject(MemberService);
|
private readonly memberService = inject(MemberService);
|
||||||
private readonly accountService = inject(AccountService);
|
private readonly accountService = inject(AccountService);
|
||||||
|
private readonly settingsService = inject(SettingsService);
|
||||||
private readonly modalService = inject(NgbModal);
|
private readonly modalService = inject(NgbModal);
|
||||||
private readonly toastr = inject(ToastrService);
|
private readonly toastr = inject(ToastrService);
|
||||||
private readonly confirmService = inject(ConfirmService);
|
private readonly confirmService = inject(ConfirmService);
|
||||||
|
|
@ -48,6 +52,7 @@ export class ManageUsersComponent implements OnInit {
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
members: Member[] = [];
|
members: Member[] = [];
|
||||||
|
settings: ServerSettings | undefined = undefined;
|
||||||
loggedInUsername = '';
|
loggedInUsername = '';
|
||||||
loadingMembers = false;
|
loadingMembers = false;
|
||||||
libraryCount: number = 0;
|
libraryCount: number = 0;
|
||||||
|
|
@ -64,6 +69,10 @@ export class ManageUsersComponent implements OnInit {
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.loadMembers();
|
this.loadMembers();
|
||||||
|
|
||||||
|
this.settingsService.getServerSettings().subscribe(settings => {
|
||||||
|
this.settings = settings;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,8 +106,11 @@ export class ManageUsersComponent implements OnInit {
|
||||||
}
|
}
|
||||||
|
|
||||||
openEditUser(member: Member) {
|
openEditUser(member: Member) {
|
||||||
|
if (!this.settings) return;
|
||||||
|
|
||||||
const modalRef = this.modalService.open(EditUserComponent, DefaultModalOptions);
|
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(() => {
|
modalRef.closed.subscribe(() => {
|
||||||
this.loadMembers();
|
this.loadMembers();
|
||||||
});
|
});
|
||||||
|
|
@ -154,4 +166,6 @@ export class ManageUsersComponent implements OnInit {
|
||||||
getRoles(member: Member) {
|
getRoles(member: Member) {
|
||||||
return member.roles.filter(item => item != 'Pleb');
|
return member.roles.filter(item => item != 'Pleb');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected readonly UserOwner = UserOwner;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@
|
||||||
"provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account",
|
"provisionAccounts-tooltip": "Create a new account when someone logs in via OIDC, without already having an account",
|
||||||
"requireVerifiedEmail": "Require verified emails",
|
"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",
|
"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",
|
||||||
"provisionUserSettings": "Provision user settings",
|
"syncUserSettings": "Sync user settings with OIDC roles",
|
||||||
"provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information",
|
"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": "Auto login",
|
||||||
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen",
|
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen",
|
||||||
"disablePasswordAuthentication": "Disable password authentication",
|
"disablePasswordAuthentication": "Disable password authentication",
|
||||||
|
|
@ -42,6 +42,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"creation-source-pipe": {
|
||||||
|
"native": "Native",
|
||||||
|
"oidc": "OpenID Connect"
|
||||||
|
},
|
||||||
|
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"no-libraries": "There are no libraries setup yet. Create some in",
|
"no-libraries": "There are no libraries setup yet. Create some in",
|
||||||
"server-settings-link": "Server settings",
|
"server-settings-link": "Server settings",
|
||||||
|
|
@ -62,7 +67,12 @@
|
||||||
"cancel": "{{common.cancel}}",
|
"cancel": "{{common.cancel}}",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"update": "Update",
|
"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.",
|
||||||
|
"creationSource": "User type",
|
||||||
|
"creationSource-tooltip": "Native users will never be synced with OIDC"
|
||||||
},
|
},
|
||||||
|
|
||||||
"user-scrobble-history": {
|
"user-scrobble-history": {
|
||||||
|
|
@ -2461,7 +2471,8 @@
|
||||||
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
|
"email-not-verified": "Your email must be verified to allow logging in via OpenID Connect",
|
||||||
"no-account": "No matching account found",
|
"no-account": "No matching account found",
|
||||||
"disabled-account": "This account is disabled, please contact an administrator",
|
"disabled-account": "This account is disabled, please contact an administrator",
|
||||||
"creating-user": "Failed to create a new user, 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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue