Add default values for when Sync is off

This commit is contained in:
Amelia 2025-07-01 20:02:37 +02:00
parent 9fb29dec20
commit a122ae07a9
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
12 changed files with 203 additions and 42 deletions

View file

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

View file

@ -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<OidcService> logger, UserManager<AppUser> 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<OidcService> logger, UserManager<AppUser> 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<OidcService> logger, UserManager<AppUser> userM
await unitOfWork.CommitAsync();
}
private async Task<AppUser?> NewUserFromOpenIdConnect(OidcConfigDto settings, ClaimsPrincipal claimsPrincipal)
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 = 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<OidcService> logger, UserManager<AppUser> 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<OidcService> logger, UserManager<AppUser> 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<OidcService> logger, UserManager<AppUser> 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;

View file

@ -12,13 +12,17 @@ export class AgeRatingPipe implements PipeTransform {
private readonly translocoService = inject(TranslocoService);
transform(value: AgeRating | AgeRatingDto | undefined): string {
transform(value: AgeRating | AgeRatingDto | undefined | string): string {
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
if (value.hasOwnProperty('title')) {
return (value as AgeRatingDto).title;
}
if (typeof value === 'string') {
value = parseInt(value, 10) as AgeRating;
}
switch (value) {
case AgeRating.Unknown:
return this.translocoService.translate('age-rating-pipe.unknown');

View file

@ -3,7 +3,7 @@ import {UserOwner} from "../_models/user";
import {translate} from "@jsverse/transloco";
@Pipe({
name: 'creationSourcePipe'
name: 'userOwnerPipe'
})
export class UserOwnerPipe implements PipeTransform {

View file

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

View file

@ -78,12 +78,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>
<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>
<option [value]="source">{{source | userOwnerPipe}}</option>
}
</select>
</ng-template>

View file

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

View file

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

View file

@ -141,6 +141,55 @@
</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>

View file

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

View file

@ -3,7 +3,7 @@ import {
ChangeDetectorRef,
Component,
EventEmitter,
inject,
inject, input,
Input,
OnInit,
Output
@ -33,6 +33,7 @@ export class RoleSelectorComponent implements OnInit {
* This must have roles
*/
@Input() member: Member | undefined | User;
preSelectedRoles = input<string[]>([]);
/**
* Allows the selection of Admin role
*/
@ -77,6 +78,13 @@ export class RoleSelectorComponent implements OnInit {
foundRole[0].selected = true;
}
});
} else if (this.preSelectedRoles().length > 0) {
this.preSelectedRoles().forEach((role) => {
const foundRole = this.selectedRoles.filter(item => item.data === role);
if (foundRole.length > 0) {
foundRole[0].selected = true;
}
});
} else {
// For new users, preselect LoginRole
this.selectedRoles.forEach(role => {

View file

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