From a122ae07a9bfde8ab42752bbe15b60bd5dce7994 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:02:37 +0200 Subject: [PATCH] Add default values for when Sync is off --- API/DTOs/Settings/OidcConfigDto.cs | 14 ++++ API/Services/OidcService.cs | 63 ++++++++++++----- UI/Web/src/app/_pipes/age-rating.pipe.ts | 6 +- UI/Web/src/app/_pipes/user-owner.pipe.ts | 2 +- UI/Web/src/app/admin/_models/oidc-config.ts | 5 ++ .../admin/edit-user/edit-user.component.html | 4 +- .../admin/edit-user/edit-user.component.ts | 2 +- .../library-selector.component.ts | 11 ++- .../manage-open-idconnect.component.html | 49 +++++++++++++ .../manage-open-idconnect.component.ts | 69 +++++++++++++++---- .../role-selector/role-selector.component.ts | 10 ++- UI/Web/src/assets/langs/en.json | 10 ++- 12 files changed, 203 insertions(+), 42 deletions(-) diff --git a/API/DTOs/Settings/OidcConfigDto.cs b/API/DTOs/Settings/OidcConfigDto.cs index 81b153cad..6b4beca76 100644 --- a/API/DTOs/Settings/OidcConfigDto.cs +++ b/API/DTOs/Settings/OidcConfigDto.cs @@ -1,5 +1,8 @@ #nullable enable +using System.Collections.Generic; +using API.Entities.Enums; + namespace API.DTOs.Settings; public record OidcConfigDto: OidcPublicConfigDto @@ -17,6 +20,17 @@ public record OidcConfigDto: OidcPublicConfigDto /// public bool SyncUserSettings { get; set; } + // Default values used when SyncUserSettings is false + #region Default user settings + + public List DefaultRoles { get; set; } = []; + public List DefaultLibraries { get; set; } = []; + public AgeRating DefaultAgeRating { get; set; } = AgeRating.Unknown; + public bool DefaultIncludeUnknowns { get; set; } = false; + + #endregion + + /// /// Returns true if the has been set /// diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index 5692621ed..aefde66a1 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Security.Claims; using System.Threading.Tasks; @@ -56,10 +57,7 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.missing-external-id"); var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); - if (user != null) - { - return user; - } + if (user != null) return user; var email = principal.FindFirstValue(ClaimTypes.Email); if (string.IsNullOrEmpty(email)) @@ -68,18 +66,23 @@ public class OidcService(ILogger logger, UserManager userM 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 {Name} has matched on email to {ExternalId}", user.UserName, 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 unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences | AppUserIncludes.SideNavStreams) - ?? await NewUserFromOpenIdConnect(settings, principal); + user = await NewUserFromOpenIdConnect(settings, principal, externalId); if (user == null) return null; - user.ExternalId = externalId; - - await SyncUserSettings(settings, principal, user); - var roles = await userManager.GetRolesAsync(user); if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) throw new KavitaException("errors.oidc.disabled-account"); @@ -98,14 +101,15 @@ public class OidcService(ILogger logger, UserManager userM await unitOfWork.CommitAsync(); } - private async Task NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal) + private async Task 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 = claimsPrincipal.FindFirstValue(ClaimTypes.Name); + var name = claimsPrincipal.FindFirstValue(JwtRegisteredClaimNames.PreferredUsername); + name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Name); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.GivenName); name ??= claimsPrincipal.FindFirstValue(ClaimTypes.Surname); name ??= emailClaim.Value; @@ -117,6 +121,8 @@ public class OidcService(ILogger logger, UserManager userM name = 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(); @@ -129,10 +135,6 @@ public class OidcService(ILogger logger, UserManager userM throw new KavitaException("errors.oidc.creating-user"); } - user.Owner = AppUserOwner.OpenIdConnect; - AddDefaultStreamsToUser(user, mapper); - await AddDefaultReadingProfileToUser(user); - if (settings.RequireVerifiedEmail) { // Email has been verified by OpenID Connect provider @@ -140,13 +142,36 @@ public class OidcService(ILogger logger, UserManager userM await userManager.ConfirmEmailAsync(user, token); } - await userManager.AddToRoleAsync(user, PolicyConstants.LoginRole); - await userManager.AddToRoleAsync(user, PolicyConstants.PlebRole); + user.ExternalId = externalId; + user.Owner = AppUserOwner.OpenIdConnect; + + AddDefaultStreamsToUser(user, mapper); + await 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; + + // 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; diff --git a/UI/Web/src/app/_pipes/age-rating.pipe.ts b/UI/Web/src/app/_pipes/age-rating.pipe.ts index f99a77f72..ce5ed31fe 100644 --- a/UI/Web/src/app/_pipes/age-rating.pipe.ts +++ b/UI/Web/src/app/_pipes/age-rating.pipe.ts @@ -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'); diff --git a/UI/Web/src/app/_pipes/user-owner.pipe.ts b/UI/Web/src/app/_pipes/user-owner.pipe.ts index b18f9d205..323bdbed1 100644 --- a/UI/Web/src/app/_pipes/user-owner.pipe.ts +++ b/UI/Web/src/app/_pipes/user-owner.pipe.ts @@ -3,7 +3,7 @@ import {UserOwner} from "../_models/user"; import {translate} from "@jsverse/transloco"; @Pipe({ - name: 'creationSourcePipe' + name: 'userOwnerPipe' }) export class UserOwnerPipe implements PipeTransform { diff --git a/UI/Web/src/app/admin/_models/oidc-config.ts b/UI/Web/src/app/admin/_models/oidc-config.ts index 2c56066bd..d48cdf088 100644 --- a/UI/Web/src/app/admin/_models/oidc-config.ts +++ b/UI/Web/src/app/admin/_models/oidc-config.ts @@ -1,3 +1,4 @@ +import {AgeRating} from "../../_models/metadata/age-rating"; export interface OidcConfig { authority: string; @@ -8,6 +9,10 @@ export interface OidcConfig { autoLogin: boolean; disablePasswordAuthentication: boolean; providerName: string; + defaultRoles: string[]; + defaultLibraries: number[]; + defaultAgeRating: AgeRating; + defaultIncludeUnknowns: boolean; } export interface OidcPublicConfig { diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html index b6e975acb..66cae449b 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.html +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -78,12 +78,12 @@ @if (userForm.get('owner'); as formControl) { -
{{member().owner | UserOwnerPipe}}
+
{{member().owner | userOwnerPipe}}
diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts index 7ae919bad..c36bfc135 100644 --- a/UI/Web/src/app/admin/edit-user/edit-user.component.ts +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -77,7 +77,7 @@ 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('creationSource', new FormControl(this.member().owner, [Validators.required])); + this.userForm.addControl('owner', new FormControl(this.member().owner, [Validators.required])); // TODO: Rework, bad hack // Work around isLocked so we're able to downgrade users diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.ts b/UI/Web/src/app/admin/library-selector/library-selector.component.ts index d562f9601..7445ed46c 100644 --- a/UI/Web/src/app/admin/library-selector/library-selector.component.ts +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.ts @@ -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([]); @Output() selected: EventEmitter> = new EventEmitter>(); 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(); } diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html index 4623d4b5d..a3344bcb4 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.html @@ -141,6 +141,55 @@ +
+

{{t('defaults')}}

+
{{t('defaults-requirement')}}
+ + +
+ @if(settingsForm.get('defaultAgeRating'); as formControl) { + + +
{{formControl.value | ageRating}}
+
+ + + +
+ } +
+ +
+ @if(settingsForm.get('defaultIncludeUnknowns'); as formControl) { + + +
+ +
+
+
+ } +
+ + @if (this.oidcSettings()) { +
+
+ +
+ +
+ +
+
+ } + +
+ diff --git a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts index d417dba54..c2419443c 100644 --- a/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts +++ b/UI/Web/src/app/admin/manage-open-idconnect/manage-open-idconnect.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectorRef, Component, DestroyRef, OnInit} from '@angular/core'; +import {ChangeDetectorRef, Component, DestroyRef, effect, OnInit, signal} from '@angular/core'; import {TranslocoDirective} from "@jsverse/transloco"; import {ServerSettings} from "../_models/server-settings"; import { @@ -16,6 +16,16 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett 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', @@ -23,7 +33,10 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; TranslocoDirective, ReactiveFormsModule, SettingItemComponent, - SettingSwitchComponent + SettingSwitchComponent, + AgeRatingPipe, + LibrarySelectorComponent, + RoleSelectorComponent ], templateUrl: './manage-open-idconnect.component.html', styleUrl: './manage-open-idconnect.component.scss' @@ -31,30 +44,43 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; export class ManageOpenIDConnectComponent implements OnInit { serverSettings!: ServerSettings; - oidcSettings!: OidcConfig; + oidcSettings = signal(undefined); settingsForm: FormGroup = new FormGroup({}); + ageRatings = signal([]); + selectedLibraries = signal([]); + selectedRoles = signal([]); + 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 = this.serverSettings.oidcConfig; + 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.oidcSettings.authority, [], [this.authorityValidator()])); - this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')])); - this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, [])); - this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, [])); - this.settingsForm.addControl('syncUserSettings', new FormControl(this.oidcSettings.syncUserSettings, [])); - this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, [])); - this.settingsForm.addControl('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, [])); - this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, [])); + 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( @@ -64,7 +90,7 @@ export class ManageOpenIDConnectComponent implements OnInit { 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; + return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId; }), tap(() => this.save()) ).subscribe(); @@ -72,17 +98,30 @@ export class ManageOpenIDConnectComponent implements OnInit { }); } + 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) return; + 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 = data.oidcConfig; + this.oidcSettings.set(data.oidcConfig); this.cdRef.markForCheck(); }, error: error => { diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.ts b/UI/Web/src/app/admin/role-selector/role-selector.component.ts index d793e55b3..ab9bbb6f5 100644 --- a/UI/Web/src/app/admin/role-selector/role-selector.component.ts +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.ts @@ -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([]); /** * 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 => { diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index f9a347480..8536415fb 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -38,7 +38,15 @@ "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" + "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}}" } },