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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,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];
|
||||
|
|
|
|||
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;
|
||||
provisionAccounts: boolean;
|
||||
requireVerifiedEmail: boolean;
|
||||
provisionUserSettings: boolean;
|
||||
syncUserSettings: boolean;
|
||||
autoLogin: boolean;
|
||||
disablePasswordAuthentication: boolean;
|
||||
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-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">
|
||||
|
|
@ -59,21 +72,41 @@
|
|||
</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 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 +116,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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
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 {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";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
|
||||
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
|
||||
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
|
@ -22,7 +36,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, SettingItemComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
|
|
@ -32,7 +46,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 +75,31 @@ 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('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(
|
||||
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 +124,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,11 +104,11 @@
|
|||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('provisionUserSettings'); as formControl) {
|
||||
<app-setting-switch [title]="t('provisionUserSettings')" [subtitle]="t('provisionUserSettings-tooltip')">
|
||||
@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="provisionUserSettings" type="checkbox" class="form-check-input" formControlName="provisionUserSettings">
|
||||
<input id="syncUserSettings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
|
||||
</div>
|
||||
</ng-template>
|
||||
</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('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
|
||||
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('disablePasswordAuthentication', new FormControl(this.oidcSettings.disablePasswordAuthentication, []));
|
||||
this.settingsForm.addControl('providerName', new FormControl(this.oidcSettings.providerName, []));
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"></th>
|
||||
<th scope="col">{{t('name-header')}}</th>
|
||||
<th scope="col">{{t('last-active-header')}}</th>
|
||||
<th scope="col">{{t('sharing-header')}}</th>
|
||||
|
|
@ -20,6 +21,18 @@
|
|||
<tbody>
|
||||
@for(member of members; track member.username + member.lastActiveUtc + member.roles.length; let idx = $index) {
|
||||
<tr>
|
||||
<td>
|
||||
<div class="-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}}">
|
||||
<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,9 @@ 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";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
|
|
@ -31,7 +34,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]
|
||||
})
|
||||
export class ManageUsersComponent implements OnInit {
|
||||
|
||||
|
|
@ -41,6 +44,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 +52,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 +69,10 @@ export class ManageUsersComponent implements OnInit {
|
|||
|
||||
ngOnInit(): void {
|
||||
this.loadMembers();
|
||||
|
||||
this.settingsService.getServerSettings().subscribe(settings => {
|
||||
this.settings = settings;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -97,8 +106,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 +166,6 @@ export class ManageUsersComponent implements OnInit {
|
|||
getRoles(member: Member) {
|
||||
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",
|
||||
"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",
|
||||
"provisionUserSettings": "Provision user settings",
|
||||
"provisionUserSettings-tooltip": "Synchronise Kavita user settings (roles, libraries, age rating) with those provided by the OIDC. See documentation for more information",
|
||||
"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",
|
||||
|
|
@ -42,6 +42,11 @@
|
|||
}
|
||||
},
|
||||
|
||||
"creation-source-pipe": {
|
||||
"native": "Native",
|
||||
"oidc": "OpenID Connect"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
"no-libraries": "There are no libraries setup yet. Create some in",
|
||||
"server-settings-link": "Server settings",
|
||||
|
|
@ -62,7 +67,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.",
|
||||
"creationSource": "User type",
|
||||
"creationSource-tooltip": "Native users will never be synced with OIDC"
|
||||
},
|
||||
|
||||
"user-scrobble-history": {
|
||||
|
|
@ -2461,7 +2471,8 @@
|
|||
"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"
|
||||
"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