mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-05-27 01:52:36 -04:00
OpenID Connect support (#3975)
Co-authored-by: DieselTech <30128380+DieselTech@users.noreply.github.com> Co-authored-by: majora2007 <josephmajora@gmail.com>
This commit is contained in:
@@ -1,16 +1,21 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import { CanActivate, Router } from '@angular/router';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map, take } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class AuthGuard implements CanActivate {
|
||||
public urlKey: string = 'kavita--auth-intersection-url';
|
||||
|
||||
public static urlKey: string = 'kavita--auth-intersection-url';
|
||||
|
||||
baseURL = inject(APP_BASE_HREF);
|
||||
|
||||
constructor(private accountService: AccountService,
|
||||
private router: Router,
|
||||
private toastr: ToastrService,
|
||||
@@ -23,7 +28,10 @@ export class AuthGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
localStorage.setItem(this.urlKey, window.location.pathname);
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/login' && !path.startsWith(this.baseURL + "registration") && path !== '') {
|
||||
localStorage.setItem(AuthGuard.urlKey, path);
|
||||
}
|
||||
this.router.navigateByUrl('/login');
|
||||
return false;
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Injectable} from '@angular/core';
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import { HttpRequest, HttpHandler, HttpEvent, HttpInterceptor } from '@angular/common/http';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { Router } from '@angular/router';
|
||||
@@ -6,9 +6,14 @@ import { ToastrService } from 'ngx-toastr';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { AccountService } from '../_services/account.service';
|
||||
import {translate, TranslocoService} from "@jsverse/transloco";
|
||||
import {AuthGuard} from "../_guards/auth.guard";
|
||||
import {APP_BASE_HREF} from "@angular/common";
|
||||
|
||||
@Injectable()
|
||||
export class ErrorInterceptor implements HttpInterceptor {
|
||||
|
||||
baseURL = inject(APP_BASE_HREF);
|
||||
|
||||
constructor(private router: Router, private toastr: ToastrService,
|
||||
private accountService: AccountService,
|
||||
private translocoService: TranslocoService) {}
|
||||
@@ -26,7 +31,7 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
this.handleValidationError(error);
|
||||
break;
|
||||
case 401:
|
||||
this.handleAuthError(error);
|
||||
this.handleAuthError(request, error);
|
||||
break;
|
||||
case 404:
|
||||
this.handleNotFound(error);
|
||||
@@ -114,19 +119,31 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
console.error('500 error:', error);
|
||||
}
|
||||
|
||||
private handleAuthError(error: any) {
|
||||
private handleAuthError(req: HttpRequest<unknown>, error: any) {
|
||||
// Special hack for register url, to not care about auth
|
||||
if (location.href.includes('/registration/confirm-email?token=')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = window.location.pathname;
|
||||
if (path !== '/login' && !path.startsWith(this.baseURL+"registration") && path !== '') {
|
||||
localStorage.setItem(AuthGuard.urlKey, path);
|
||||
}
|
||||
|
||||
if (error.error && error.error !== 'Unauthorized') {
|
||||
this.toast(translate(error.error));
|
||||
}
|
||||
|
||||
// NOTE: Signin has error.error or error.statusText available.
|
||||
// if statement is due to http/2 spec issue: https://github.com/angular/angular/issues/23334
|
||||
this.accountService.logout();
|
||||
|
||||
// Ensure AutoLogin is skipped when the OIDC endpoint is called
|
||||
this.accountService.logout(req.method === 'GET' && req.url.endsWith('/api/account'));
|
||||
}
|
||||
|
||||
// Assume the title is already translated
|
||||
private toast(message: string, title?: string) {
|
||||
if (message.startsWith('errors.')) {
|
||||
if ((message+'').startsWith('errors.')) {
|
||||
this.toastr.error(this.translocoService.translate(message), title);
|
||||
} else {
|
||||
this.toastr.error(message, title);
|
||||
|
||||
@@ -10,17 +10,15 @@ export class JwtInterceptor implements HttpInterceptor {
|
||||
constructor(private accountService: AccountService) {}
|
||||
|
||||
intercept(request: HttpRequest<unknown>, next: HttpHandler): Observable<HttpEvent<unknown>> {
|
||||
return this.accountService.currentUser$.pipe(
|
||||
take(1),
|
||||
switchMap(user => {
|
||||
if (user) {
|
||||
request = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${user.token}`
|
||||
}
|
||||
});
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (user && user.token) {
|
||||
request = request.clone({
|
||||
setHeaders: {
|
||||
Authorization: `Bearer ${user.token}`
|
||||
}
|
||||
return next.handle(request);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return next.handle(request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {AgeRestriction} from '../metadata/age-restriction';
|
||||
import {Library} from '../library/library';
|
||||
import {IdentityProvider} from "../user";
|
||||
|
||||
export interface Member {
|
||||
id: number;
|
||||
@@ -13,4 +14,5 @@ export interface Member {
|
||||
libraries: Library[];
|
||||
ageRestriction: AgeRestriction;
|
||||
isPending: boolean;
|
||||
identityProvider: IdentityProvider;
|
||||
}
|
||||
|
||||
@@ -13,4 +13,12 @@ export interface User {
|
||||
ageRestriction: AgeRestriction;
|
||||
hasRunScrobbleEventGeneration: boolean;
|
||||
scrobbleEventGenerationRan: string; // datetime
|
||||
identityProvider: IdentityProvider,
|
||||
}
|
||||
|
||||
export enum IdentityProvider {
|
||||
Kavita = 0,
|
||||
OpenIdConnect = 1,
|
||||
}
|
||||
|
||||
export const IdentityProviders: IdentityProvider[] = [IdentityProvider.Kavita, IdentityProvider.OpenIdConnect];
|
||||
|
||||
@@ -12,13 +12,17 @@ export class AgeRatingPipe implements PipeTransform {
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: AgeRating | AgeRatingDto | undefined): string {
|
||||
transform(value: AgeRating | AgeRatingDto | undefined | string): string {
|
||||
if (value === undefined || value === null) return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
||||
if (value.hasOwnProperty('title')) {
|
||||
return (value as AgeRatingDto).title;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
value = parseInt(value, 10) as AgeRating;
|
||||
}
|
||||
|
||||
switch (value) {
|
||||
case AgeRating.Unknown:
|
||||
return this.translocoService.translate('age-rating-pipe.unknown');
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
import {IdentityProvider} from "../_models/user";
|
||||
import {translate} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'identityProviderPipe'
|
||||
})
|
||||
export class IdentityProviderPipePipe implements PipeTransform {
|
||||
|
||||
transform(value: IdentityProvider): string {
|
||||
switch (value) {
|
||||
case IdentityProvider.Kavita:
|
||||
return translate("identity-provider-pipe.kavita");
|
||||
case IdentityProvider.OpenIdConnect:
|
||||
return translate("identity-provider-pipe.oidc");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {HttpClient, HttpHeaders} from '@angular/common/http';
|
||||
import {DestroyRef, inject, Injectable} from '@angular/core';
|
||||
import {Observable, of, ReplaySubject, shareReplay} from 'rxjs';
|
||||
import {filter, map, switchMap, tap} from 'rxjs/operators';
|
||||
@@ -13,7 +13,7 @@ import {UserUpdateEvent} from '../_models/events/user-update-event';
|
||||
import {AgeRating} from '../_models/metadata/age-rating';
|
||||
import {AgeRestriction} from '../_models/metadata/age-restriction';
|
||||
import {TextResonse} from '../_types/text-response';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
|
||||
import {Action} from "./action-factory.service";
|
||||
import {LicenseService} from "./license.service";
|
||||
import {LocalizationService} from "./localization.service";
|
||||
@@ -63,7 +63,7 @@ export class AccountService {
|
||||
return this.hasAdminRole(u);
|
||||
}), shareReplay({bufferSize: 1, refCount: true}));
|
||||
|
||||
|
||||
public readonly currentUserSignal = toSignal(this.currentUserSource);
|
||||
|
||||
/**
|
||||
* SetTimeout handler for keeping track of refresh token call
|
||||
@@ -205,14 +205,22 @@ export class AccountService {
|
||||
);
|
||||
}
|
||||
|
||||
getAccount() {
|
||||
return this.httpClient.get<User>(this.baseUrl + 'account').pipe(
|
||||
tap((response: User) => {
|
||||
const user = response;
|
||||
if (user) {
|
||||
this.setCurrentUser(user);
|
||||
}
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
);
|
||||
}
|
||||
|
||||
setCurrentUser(user?: User, refreshConnections = true) {
|
||||
|
||||
const isSameUser = this.currentUser === user;
|
||||
if (user) {
|
||||
user.roles = [];
|
||||
const roles = this.getDecodedToken(user.token).role;
|
||||
Array.isArray(roles) ? user.roles = roles : user.roles.push(roles);
|
||||
|
||||
localStorage.setItem(this.userKey, JSON.stringify(user));
|
||||
localStorage.setItem(AccountService.lastLoginKey, user.username);
|
||||
|
||||
@@ -240,18 +248,30 @@ export class AccountService {
|
||||
this.messageHub.createHubConnection(this.currentUser);
|
||||
this.licenseService.hasValidLicense().subscribe();
|
||||
}
|
||||
this.startRefreshTokenTimer();
|
||||
if (this.currentUser.token) {
|
||||
this.startRefreshTokenTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
logout(skipAutoLogin: boolean = false) {
|
||||
const user = this.currentUserSignal();
|
||||
if (!user) return;
|
||||
|
||||
localStorage.removeItem(this.userKey);
|
||||
this.currentUserSource.next(undefined);
|
||||
this.currentUser = undefined;
|
||||
this.stopRefreshTokenTimer();
|
||||
this.messageHub.stopHubConnection();
|
||||
// Upon logout, perform redirection
|
||||
this.router.navigateByUrl('/login');
|
||||
|
||||
if (!user.token) {
|
||||
window.location.href = '/oidc/logout';
|
||||
return;
|
||||
}
|
||||
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {skipAutoLogin: skipAutoLogin}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -269,6 +289,11 @@ export class AccountService {
|
||||
);
|
||||
}
|
||||
|
||||
isOidcAuthenticated() {
|
||||
return this.httpClient.get<string>(this.baseUrl + 'account/oidc-authenticated', TextResonse)
|
||||
.pipe(map(res => res == "true"));
|
||||
}
|
||||
|
||||
isEmailConfirmed() {
|
||||
return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed');
|
||||
}
|
||||
@@ -410,7 +435,8 @@ export class AccountService {
|
||||
|
||||
|
||||
private refreshToken() {
|
||||
if (this.currentUser === null || this.currentUser === undefined || !this.isOnline) return of();
|
||||
if (this.currentUser === null || this.currentUser === undefined || !this.isOnline || !this.currentUser.token) return of();
|
||||
|
||||
return this.httpClient.post<{token: string, refreshToken: string}>(this.baseUrl + 'account/refresh-token',
|
||||
{token: this.currentUser.token, refreshToken: this.currentUser.refreshToken}).pipe(map(user => {
|
||||
if (this.currentUser) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {NavigationEnd, Router} from "@angular/router";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
|
||||
import {WikiLink} from "../_models/wiki";
|
||||
import {AuthGuard} from "../_guards/auth.guard";
|
||||
import {SettingsService} from "../admin/settings.service";
|
||||
|
||||
/**
|
||||
* NavItem used to construct the dropdown or NavLinkModal on mobile
|
||||
@@ -173,10 +175,24 @@ export class NavService {
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.accountService.logout();
|
||||
this.hideNavBar();
|
||||
this.hideSideNav();
|
||||
this.router.navigateByUrl('/login');
|
||||
this.accountService.logout();
|
||||
}
|
||||
|
||||
handleLogin() {
|
||||
this.showNavBar();
|
||||
this.showSideNav();
|
||||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem(AuthGuard.urlKey);
|
||||
if (pageResume && pageResume !== '/login') {
|
||||
localStorage.setItem(AuthGuard.urlKey, '');
|
||||
this.router.navigateByUrl(pageResume);
|
||||
} else {
|
||||
localStorage.setItem(AuthGuard.urlKey, '');
|
||||
this.router.navigateByUrl('/home');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
|
||||
export interface OidcPublicConfig {
|
||||
autoLogin: boolean;
|
||||
disablePasswordAuthentication: boolean;
|
||||
providerName: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface OidcConfig extends OidcPublicConfig {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
secret: string;
|
||||
provisionAccounts: boolean;
|
||||
requireVerifiedEmail: boolean;
|
||||
syncUserSettings: boolean;
|
||||
rolesPrefix: string;
|
||||
rolesClaim: string;
|
||||
customScopes: string[];
|
||||
defaultRoles: string[];
|
||||
defaultLibraries: number[];
|
||||
defaultAgeRestriction: AgeRating;
|
||||
defaultIncludeUnknowns: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {EncodeFormat} from "./encode-format";
|
||||
import {CoverImageSize} from "./cover-image-size";
|
||||
import {SmtpConfig} from "./smtp-config";
|
||||
import {OidcConfig} from "./oidc-config";
|
||||
|
||||
export interface ServerSettings {
|
||||
cacheDirectory: string;
|
||||
@@ -25,6 +26,7 @@ export interface ServerSettings {
|
||||
onDeckUpdateDays: number;
|
||||
coverImageSize: CoverImageSize;
|
||||
smtpConfig: SmtpConfig;
|
||||
oidcConfig: OidcConfig;
|
||||
installId: string;
|
||||
installVersion: string;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
<ng-container *transloco="let t; read: 'edit-user'">
|
||||
<ng-container *transloco="let t; prefix: 'edit-user'">
|
||||
<div class="modal-container">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member.username | sentenceCase}}</h5>
|
||||
<h5 class="modal-title" id="modal-basic-title">{{t('edit')}} {{member().username | sentenceCase}}</h5>
|
||||
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()">
|
||||
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body scrollable-modal">
|
||||
|
||||
@if (!isLocked() && member().identityProvider === IdentityProvider.OpenIdConnect) {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('out-of-sync')}}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isLocked()) {
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('oidc-managed')}}
|
||||
</div>
|
||||
}
|
||||
|
||||
<form [formGroup]="userForm">
|
||||
<h4>{{t('account-detail-title')}}</h4>
|
||||
<div class="row g-0 mb-2">
|
||||
<div class="col-md-6 col-sm-12 pe-4">
|
||||
<div class="col-md-4 col-sm-12 pe-4">
|
||||
@if (userForm.get('identityProvider'); as formControl) {
|
||||
<label for="identityProvider" class="form-label">{{t('identity-provider')}}</label>
|
||||
<select class="form-select" id="identityProvider" formControlName="identityProvider">
|
||||
@for (idp of IdentityProviders; track idp) {
|
||||
<option [value]="idp">{{idp | identityProviderPipe}}</option>
|
||||
}
|
||||
</select>
|
||||
<span class="text-muted">{{t('identity-provider-tooltip')}}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4 col-sm-12 pe-4">
|
||||
@if(userForm.get('username'); as formControl) {
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">{{t('username')}}</label>
|
||||
@@ -33,7 +57,7 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-6 col-sm-12">
|
||||
<div class="col-md-4 col-sm-12">
|
||||
@if(userForm.get('email'); as formControl) {
|
||||
<div class="mb-3" style="width:100%">
|
||||
<label for="email" class="form-label">{{t('email')}}</label>
|
||||
@@ -63,17 +87,17 @@
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-12">
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
|
||||
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member()"></app-restriction-selector>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mb-3">
|
||||
<div class="col-md-6 pe-4">
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
|
||||
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member()"></app-role-selector>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
|
||||
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member()"></app-library-selector>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -83,7 +107,7 @@
|
||||
<button type="button" class="btn btn-secondary" (click)="close()">
|
||||
{{t('cancel')}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
|
||||
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isLocked() || isSaving || !userForm.valid">
|
||||
@if (isSaving) {
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.text-muted {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
inject,
|
||||
model,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import {FormControl, FormGroup, ReactiveFormsModule, Validators} from '@angular/forms';
|
||||
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {AgeRestriction} from 'src/app/_models/metadata/age-restriction';
|
||||
@@ -9,20 +18,23 @@ 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 {IdentityProvider, IdentityProviders} from "../../_models/user";
|
||||
import {IdentityProviderPipePipe} from "../../_pipes/identity-provider.pipe";
|
||||
|
||||
const AllowedUsernameCharacters = /^[\sa-zA-Z0-9\-._@+/\s]*$/;
|
||||
const AllowedUsernameCharacters = /^[a-zA-Z0-9\-._@+/]*$/;
|
||||
const EmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
@Component({
|
||||
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, IdentityProviderPipePipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class EditUserComponent implements OnInit {
|
||||
@@ -32,7 +44,14 @@ export class EditUserComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
protected readonly modal = inject(NgbActiveModal);
|
||||
|
||||
@Input({required: true}) member!: Member;
|
||||
member = model.required<Member>();
|
||||
settings = model.required<ServerSettings>();
|
||||
|
||||
isLocked = computed(() => {
|
||||
const setting = this.settings();
|
||||
const member = this.member();
|
||||
return setting.oidcConfig.syncUserSettings && member.identityProvider === IdentityProvider.OpenIdConnect;
|
||||
});
|
||||
|
||||
selectedRoles: Array<string> = [];
|
||||
selectedLibraries: Array<number> = [];
|
||||
@@ -52,18 +71,29 @@ export class EditUserComponent implements OnInit {
|
||||
|
||||
|
||||
ngOnInit(): void {
|
||||
this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required]));
|
||||
this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
|
||||
this.userForm.addControl('email', new FormControl(this.member().email, [Validators.required]));
|
||||
this.userForm.addControl('username', new FormControl(this.member().username, [Validators.required, Validators.pattern(AllowedUsernameCharacters)]));
|
||||
this.userForm.addControl('identityProvider', new FormControl(this.member().identityProvider, [Validators.required]));
|
||||
|
||||
this.userForm.get('identityProvider')!.valueChanges.pipe(
|
||||
tap(value => {
|
||||
const newIdentityProvider = parseInt(value, 10) as IdentityProvider;
|
||||
if (newIdentityProvider === IdentityProvider.OpenIdConnect) return;
|
||||
this.member.set({
|
||||
...this.member(),
|
||||
identityProvider: newIdentityProvider,
|
||||
})
|
||||
})).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 +118,23 @@ 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.identityProvider = parseInt(model.identityProvider, 10) as IdentityProvider;
|
||||
|
||||
this.accountService.update(model).subscribe(() => {
|
||||
this.modal.close(true);
|
||||
|
||||
this.accountService.update(model).subscribe({
|
||||
next: () => {
|
||||
this.modal.close(true);
|
||||
},
|
||||
error: err => {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected readonly IdentityProvider = IdentityProvider;
|
||||
protected readonly IdentityProviders = IdentityProviders;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
inject, input,
|
||||
Input,
|
||||
OnInit,
|
||||
Output
|
||||
@@ -29,6 +29,8 @@ 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 +63,14 @@ export class LibrarySelectorComponent implements OnInit {
|
||||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
this.selected.emit(this.selections.selected());
|
||||
} else if (this.preSelectedLibraries().length > 0) {
|
||||
this.preSelectedLibraries().forEach((id) => {
|
||||
const foundLib = this.allLibraries.find(lib => lib.id === id);
|
||||
if (foundLib) {
|
||||
this.selections.toggle(foundLib, true, (a, b) => a.name === b.name);
|
||||
}
|
||||
});
|
||||
this.selectAll = this.selections.selected().length === this.allLibraries.length;
|
||||
}
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
<ng-container *transloco="let t; prefix:'manage-oidc-connect'">
|
||||
|
||||
<div class="position-relative">
|
||||
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save(true)">{{t('save')}}</button>
|
||||
</div>
|
||||
|
||||
<form [formGroup]="settingsForm">
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
|
||||
</div>
|
||||
|
||||
<h4>{{t('provider-title')}}</h4>
|
||||
<div class="text-muted" [innerHtml]="t('provider-tooltip') | safeHtml"></div>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('authority'); as formControl) {
|
||||
<app-setting-item [title]="t('authority-label')" [subtitle]="t('authority-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-authority" class="form-control"
|
||||
formControlName="authority" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="invalid-uri-validation" class="invalid-feedback">
|
||||
@if (formControl.errors?.invalidUri) {
|
||||
<div>{{t('invalid-uri')}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('clientId'); as formControl) {
|
||||
<app-setting-item [title]="t('client-id-label')" [subtitle]="t('client-id-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-client-id" aria-describedby="oidc-client-id-validations" class="form-control"
|
||||
formControlName="clientId" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="oidc-client-id-validations" class="invalid-feedback">
|
||||
@if (formControl.errors && formControl.errors.requiredIf) {
|
||||
<div>{{t('other-field-required', {name: 'clientId', other: formControl.errors.requiredIf.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('secret'); as formControl) {
|
||||
<app-setting-item [title]="t('secret-label')" [subtitle]="t('secret-tooltip')">
|
||||
<ng-template #view>
|
||||
@if (formControl.value) {
|
||||
{{'*'.repeat(10)}}
|
||||
} @else {
|
||||
{{ null | defaultValue }}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-secret" aria-describedby="oidc-secret-validations" class="form-control"
|
||||
formControlName="secret" type="password"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
|
||||
@if (settingsForm.dirty || !settingsForm.untouched) {
|
||||
<div id="oidc-secret-validations" class="invalid-feedback">
|
||||
@if (formControl.errors && formControl.errors.requiredIf) {
|
||||
<div>{{t('other-field-required', {name: 'secret', other: formControl.errors.requiredIf.other})}}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('behavior-title')}}</h4>
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('providerName'); as formControl) {
|
||||
<app-setting-item [title]="t('provider-name-label')" [subtitle]="t('provider-name-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oid-provider-name" aria-describedby="oidc-provider-name-validations" class="form-control"
|
||||
formControlName="providerName" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('provisionAccounts'); as formControl) {
|
||||
<app-setting-switch [title]="t('provision-accounts-label')" [subtitle]="t('provision-accounts-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="provision-accounts" type="checkbox" class="form-check-input" formControlName="provisionAccounts">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('requireVerifiedEmail'); as formControl) {
|
||||
<app-setting-switch [title]="t('require-verified-email-label')" [subtitle]="t('require-verified-email-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="require-verified-email" type="checkbox" class="form-check-input" formControlName="requireVerifiedEmail">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('syncUserSettings'); as formControl) {
|
||||
<app-setting-switch [title]="t('sync-user-settings-label')" [subtitle]="t('sync-user-settings-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="sync-user-settings" type="checkbox" class="form-check-input" formControlName="syncUserSettings">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('autoLogin'); as formControl) {
|
||||
<app-setting-switch [title]="t('auto-login-label')" [subtitle]="t('auto-login-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="auto-login" type="checkbox" class="form-check-input" formControlName="autoLogin">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('disablePasswordAuthentication'); as formControl) {
|
||||
<app-setting-switch [title]="t('disable-password-authentication-label')" [subtitle]="t('disable-password-authentication-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="disable-password-authentication" type="checkbox" class="form-check-input" formControlName="disablePasswordAuthentication">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('defaults-title')}}</h4>
|
||||
<div class="text-muted">{{t('defaults-requirement')}}</div>
|
||||
<ng-container>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if(settingsForm.get('defaultAgeRestriction'); as formControl) {
|
||||
<app-setting-item [title]="t('default-age-restriction-label')" [subtitle]="t('default-age-restriction-tooltip')">
|
||||
<ng-template #view>
|
||||
<div>{{formControl.value | ageRating}}</div>
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<select class="form-select" formControlName="defaultAgeRestriction">
|
||||
<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('default-include-unknowns-label')" [subtitle]="t('default-include-unknowns-tooltip')">
|
||||
<ng-template #switch>
|
||||
<div class="form-check form-switch float-end">
|
||||
<input id="default-include-unknowns" type="checkbox" class="form-check-input" formControlName="defaultIncludeUnknowns">
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-setting-switch>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (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>
|
||||
|
||||
<div class="setting-section-break"></div>
|
||||
<h4>{{t('advanced-title')}}</h4>
|
||||
<div class="text-muted">{{t('advanced-tooltip')}}</div>
|
||||
|
||||
<ng-container>
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('rolesPrefix'); as formControl) {
|
||||
<app-setting-item [title]="t('roles-prefix-label')" [subtitle]="t('roles-prefix-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oidc-roles-prefix" class="form-control"
|
||||
formControlName="rolesPrefix" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('rolesClaim'); as formControl) {
|
||||
<app-setting-item [title]="t('roles-claim-label')" [subtitle]="t('roles-claim-tooltip')">
|
||||
<ng-template #view>
|
||||
{{formControl.value}}
|
||||
</ng-template>
|
||||
<ng-template #edit>
|
||||
<input id="oidc-roles-claim" class="form-control"
|
||||
formControlName="rolesClaim" type="text"
|
||||
[class.is-invalid]="formControl.invalid && !formControl.untouched">
|
||||
</ng-template>
|
||||
</app-setting-item>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-0 mt-4 mb-4">
|
||||
@if (settingsForm.get('customScopes'); as formControl) {
|
||||
<app-setting-item [title]="t('custom-scopes-label')" [subtitle]="t('custom-scopes-tooltip')">
|
||||
<ng-template #view>
|
||||
@let val = breakString(formControl.value);
|
||||
|
||||
@for(opt of val; track opt) {
|
||||
<app-tag-badge>{{opt.trim()}}</app-tag-badge>
|
||||
} @empty {
|
||||
{{null | defaultValue}}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #edit>
|
||||
<textarea rows="3" id="custom-scopes" class="form-control" formControlName="customScopes"></textarea>
|
||||
</ng-template>
|
||||
|
||||
</app-setting-item>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
|
||||
</form>
|
||||
|
||||
</ng-container>
|
||||
@@ -0,0 +1,8 @@
|
||||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 5px;
|
||||
top: -42px;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
DestroyRef,
|
||||
effect,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {ServerSettings} from "../_models/server-settings";
|
||||
import {
|
||||
AbstractControl,
|
||||
AsyncValidatorFn,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn
|
||||
} from "@angular/forms";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {OidcConfig} from "../_models/oidc-config";
|
||||
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
|
||||
import {SettingSwitchComponent} from "../../settings/_components/setting-switch/setting-switch.component";
|
||||
import {debounceTime, distinctUntilChanged, filter, map, of, switchMap, tap} from "rxjs";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {RestrictionSelectorComponent} from "../../user-settings/restriction-selector/restriction-selector.component";
|
||||
import {AgeRatingPipe} from "../../_pipes/age-rating.pipe";
|
||||
import {MetadataService} from "../../_services/metadata.service";
|
||||
import {AgeRating} from "../../_models/metadata/age-rating";
|
||||
import {AgeRatingDto} from "../../_models/metadata/age-rating-dto";
|
||||
import {allRoles, Role} from "../../_services/account.service";
|
||||
import {Library} from "../../_models/library/library";
|
||||
import {LibraryService} from "../../_services/library.service";
|
||||
import {LibrarySelectorComponent} from "../library-selector/library-selector.component";
|
||||
import {RoleSelectorComponent} from "../role-selector/role-selector.component";
|
||||
import {ToastrService} from "ngx-toastr";
|
||||
import {SafeHtmlPipe} from "../../_pipes/safe-html.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-open-idconnect',
|
||||
imports: [
|
||||
TranslocoDirective,
|
||||
ReactiveFormsModule,
|
||||
SettingItemComponent,
|
||||
SettingSwitchComponent,
|
||||
AgeRatingPipe,
|
||||
LibrarySelectorComponent,
|
||||
RoleSelectorComponent,
|
||||
SafeHtmlPipe,
|
||||
DefaultValuePipe,
|
||||
TagBadgeComponent
|
||||
],
|
||||
templateUrl: './manage-open-idconnect.component.html',
|
||||
styleUrl: './manage-open-idconnect.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ManageOpenIDConnectComponent implements OnInit {
|
||||
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly metadataService = inject(MetadataService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
|
||||
serverSettings!: ServerSettings;
|
||||
settingsForm: FormGroup = new FormGroup({});
|
||||
|
||||
oidcSettings = signal<OidcConfig | undefined>(undefined);
|
||||
ageRatings = signal<AgeRatingDto[]>([]);
|
||||
selectedLibraries = signal<number[]>([]);
|
||||
selectedRoles = signal<string[]>([]);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.metadataService.getAllAgeRatings().subscribe(ratings => {
|
||||
this.ageRatings.set(ratings);
|
||||
});
|
||||
|
||||
this.settingsService.getServerSettings().subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings.set(this.serverSettings.oidcConfig);
|
||||
this.selectedRoles.set(this.serverSettings.oidcConfig.defaultRoles);
|
||||
this.selectedLibraries.set(this.serverSettings.oidcConfig.defaultLibraries);
|
||||
|
||||
this.settingsForm.addControl('authority', new FormControl(this.serverSettings.oidcConfig.authority, [], [this.authorityValidator()]));
|
||||
this.settingsForm.addControl('clientId', new FormControl(this.serverSettings.oidcConfig.clientId, [this.requiredIf('authority')]));
|
||||
this.settingsForm.addControl('secret', new FormControl(this.serverSettings.oidcConfig.secret, [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('rolesPrefix', new FormControl(this.serverSettings.oidcConfig.rolesPrefix, []));
|
||||
this.settingsForm.addControl('rolesClaim', new FormControl(this.serverSettings.oidcConfig.rolesClaim, []));
|
||||
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("defaultAgeRestriction", new FormControl(this.serverSettings.oidcConfig.defaultAgeRestriction, []));
|
||||
this.settingsForm.addControl('defaultIncludeUnknowns', new FormControl(this.serverSettings.oidcConfig.defaultIncludeUnknowns, []));
|
||||
this.settingsForm.addControl('customScopes', new FormControl(this.serverSettings.oidcConfig.customScopes.join(","), []))
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
this.settingsForm.valueChanges.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
filter(() => {
|
||||
// Do not auto save when provider settings have changed
|
||||
const settings: OidcConfig = this.settingsForm.getRawValue();
|
||||
return settings.authority == this.oidcSettings()?.authority && settings.clientId == this.oidcSettings()?.clientId;
|
||||
}),
|
||||
tap(() => this.save())
|
||||
).subscribe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateRoles(roles: string[]) {
|
||||
this.selectedRoles.set(roles);
|
||||
this.save();
|
||||
}
|
||||
|
||||
updateLibraries(libraries: Library[]) {
|
||||
this.selectedLibraries.set(libraries.map(l => l.id));
|
||||
this.save();
|
||||
}
|
||||
|
||||
save(showConfirmation: boolean = false) {
|
||||
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.defaultAgeRestriction = parseInt(newSettings.oidcConfig.defaultAgeRestriction + '', 10) as AgeRating;
|
||||
newSettings.oidcConfig.defaultRoles = this.selectedRoles();
|
||||
newSettings.oidcConfig.defaultLibraries = this.selectedLibraries();
|
||||
newSettings.oidcConfig.customScopes = (data.customScopes as string)
|
||||
.split(',').map((item: string) => item.trim())
|
||||
.filter((scope: string) => scope.length > 0);
|
||||
|
||||
this.settingsService.updateServerSettings(newSettings).subscribe({
|
||||
next: data => {
|
||||
this.serverSettings = data;
|
||||
this.oidcSettings.set(data.oidcConfig);
|
||||
this.cdRef.markForCheck();
|
||||
|
||||
if (showConfirmation) {
|
||||
this.toastr.success(translate('manage-oidc-connect.save-success'))
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
console.error(error);
|
||||
this.toastr.error(translate('errors.generic'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
breakString(s: string) {
|
||||
if (s) {
|
||||
return s.split(',');
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
authorityValidator(): AsyncValidatorFn {
|
||||
return (control: AbstractControl) => {
|
||||
let uri: string = control.value;
|
||||
if (!uri || uri.trim().length === 0) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(uri);
|
||||
} catch {
|
||||
return of({'invalidUri': {'uri': uri}} as ValidationErrors)
|
||||
}
|
||||
|
||||
return this.settingsService.ifValidAuthority(uri).pipe(map(ok => {
|
||||
if (ok) return null;
|
||||
|
||||
return {'invalidUri': {'uri': uri}} as ValidationErrors;
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
requiredIf(other: string): ValidatorFn {
|
||||
return (control): ValidationErrors | null => {
|
||||
const otherControl = this.settingsForm.get(other);
|
||||
if (!otherControl) return null;
|
||||
|
||||
if (otherControl.invalid) return null;
|
||||
|
||||
const v = otherControl.value;
|
||||
if (!v || v.length === 0) return null;
|
||||
|
||||
const own = control.value;
|
||||
if (own && own.length > 0) return null;
|
||||
|
||||
return {'requiredIf': {'other': other, 'otherValue': v}}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -145,6 +145,7 @@ export class ManageSettingsComponent implements OnInit {
|
||||
modelSettings.smtpConfig = this.serverSettings.smtpConfig;
|
||||
modelSettings.installId = this.serverSettings.installId;
|
||||
modelSettings.installVersion = this.serverSettings.installVersion;
|
||||
modelSettings.oidcConfig = this.serverSettings.oidcConfig;
|
||||
|
||||
// Disabled FormControls are not added to the value
|
||||
if (this.isDocker) {
|
||||
|
||||
@@ -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="d-flex flex-row justify-content-center align-items-center">
|
||||
@switch (member.identityProvider) {
|
||||
@case (IdentityProvider.OpenIdConnect) {
|
||||
<app-image imageUrl="assets/icons/open-id-connect-logo.svg" height="16px" width="16px"></app-image>
|
||||
}
|
||||
@case (IdentityProvider.Kavita) {
|
||||
<app-image imageUrl="assets/icons/favicon-16x16.png" height="16px" width="16px"></app-image>
|
||||
}
|
||||
}
|
||||
</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,8 +12,8 @@ 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 {TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {AsyncPipe, NgClass, NgOptimizedImage, TitleCasePipe} from '@angular/common';
|
||||
import {size, TranslocoModule, TranslocoService} from "@jsverse/transloco";
|
||||
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
|
||||
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
|
||||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
@@ -23,6 +23,10 @@ import {SentenceCasePipe} from "../../_pipes/sentence-case.pipe";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {UtcToLocaleDatePipe} from "../../_pipes/utc-to-locale-date.pipe";
|
||||
import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||
import {SettingsService} from "../settings.service";
|
||||
import {ServerSettings} from "../_models/server-settings";
|
||||
import {IdentityProvider} from "../../_models/user";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-users',
|
||||
@@ -31,7 +35,7 @@ import {RoleLocalizedPipe} from "../../_pipes/role-localized.pipe";
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgbTooltip, TagBadgeComponent, AsyncPipe, TitleCasePipe, TranslocoModule, DefaultDatePipe, NgClass,
|
||||
DefaultValuePipe, UtcToLocalTimePipe, LoadingComponent, TimeAgoPipe, SentenceCasePipe, UtcToLocaleDatePipe,
|
||||
RoleLocalizedPipe]
|
||||
RoleLocalizedPipe, ImageComponent]
|
||||
})
|
||||
export class ManageUsersComponent implements OnInit {
|
||||
|
||||
@@ -41,6 +45,7 @@ export class ManageUsersComponent implements OnInit {
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly memberService = inject(MemberService);
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
@@ -48,6 +53,7 @@ export class ManageUsersComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
members: Member[] = [];
|
||||
settings: ServerSettings | undefined = undefined;
|
||||
loggedInUsername = '';
|
||||
loadingMembers = false;
|
||||
libraryCount: number = 0;
|
||||
@@ -64,6 +70,10 @@ export class ManageUsersComponent implements OnInit {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadMembers();
|
||||
|
||||
this.settingsService.getServerSettings().subscribe(settings => {
|
||||
this.settings = settings;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -97,8 +107,11 @@ export class ManageUsersComponent implements OnInit {
|
||||
}
|
||||
|
||||
openEditUser(member: Member) {
|
||||
if (!this.settings) return;
|
||||
|
||||
const modalRef = this.modalService.open(EditUserComponent, DefaultModalOptions);
|
||||
modalRef.componentInstance.member = member;
|
||||
modalRef.componentInstance.member.set(member);
|
||||
modalRef.componentInstance.settings.set(this.settings);
|
||||
modalRef.closed.subscribe(() => {
|
||||
this.loadMembers();
|
||||
});
|
||||
@@ -154,4 +167,6 @@ export class ManageUsersComponent implements OnInit {
|
||||
getRoles(member: Member) {
|
||||
return member.roles.filter(item => item != 'Pleb');
|
||||
}
|
||||
|
||||
protected readonly IdentityProvider = IdentityProvider;
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {map, of} from 'rxjs';
|
||||
import {computed, Injectable, signal} from '@angular/core';
|
||||
import {map, of, tap} from 'rxjs';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { TextResonse } from '../_types/text-response';
|
||||
import { ServerSettings } from './_models/server-settings';
|
||||
import {MetadataSettings} from "./_models/metadata-settings";
|
||||
import {MetadataMappingsExport} from "./manage-metadata-mappings/manage-metadata-mappings.component";
|
||||
import {FieldMappingsImportResult, ImportSettings} from "../_models/import-field-mappings";
|
||||
import {OidcPublicConfig} from "./_models/oidc-config";
|
||||
|
||||
/**
|
||||
* Used only for the Test Email Service call
|
||||
@@ -30,6 +31,10 @@ export class SettingsService {
|
||||
return this.http.get<ServerSettings>(this.baseUrl + 'settings');
|
||||
}
|
||||
|
||||
getPublicOidcConfig() {
|
||||
return this.http.get<OidcPublicConfig>(this.baseUrl + "settings/oidc");
|
||||
}
|
||||
|
||||
getMetadataSettings() {
|
||||
return this.http.get<MetadataSettings>(this.baseUrl + 'settings/metadata-settings');
|
||||
}
|
||||
@@ -88,6 +93,11 @@ export class SettingsService {
|
||||
isValidCronExpression(val: string) {
|
||||
if (val === '' || val === undefined || val === null) return of(false);
|
||||
return this.http.get<string>(this.baseUrl + 'settings/is-valid-cron?cronExpression=' + val, TextResonse).pipe(map(d => d === 'true'));
|
||||
}
|
||||
|
||||
ifValidAuthority(authority: string) {
|
||||
if (authority === '' || authority === undefined || authority === null) return of(false);
|
||||
|
||||
return this.http.post<boolean>(this.baseUrl + 'settings/is-valid-authority', {authority}, TextResonse).pipe(map(r => r + '' == 'true'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
DestroyRef, effect,
|
||||
HostListener,
|
||||
inject,
|
||||
OnInit
|
||||
@@ -97,7 +97,6 @@ export class AppComponent implements OnInit {
|
||||
}), takeUntilDestroyed(this.destroyRef));
|
||||
|
||||
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
|
||||
|
||||
}
|
||||
|
||||
@HostListener('window:resize', ['$event'])
|
||||
@@ -118,9 +117,7 @@ export class AppComponent implements OnInit {
|
||||
|
||||
|
||||
setCurrentUser() {
|
||||
const user = this.accountService.getUserFromLocalStorage();
|
||||
this.accountService.setCurrentUser(user);
|
||||
|
||||
const user = this.accountService.currentUserSignal();
|
||||
if (!user) return;
|
||||
|
||||
// Bootstrap anything that's needed
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
<ng-container *transloco="let t; read: 'login'">
|
||||
<ng-container *transloco="let t; prefix: 'login'">
|
||||
<app-splash-container>
|
||||
<ng-container title><h2>{{t('title')}}</h2></ng-container>
|
||||
<ng-container body>
|
||||
<ng-container *ngIf="isLoaded">
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
|
||||
<div class="card-text">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
|
||||
<input class="form-control custom-input" formControlName="username" id="username" autocomplete="username"
|
||||
type="text" autofocus [placeholder]="t('username')">
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<label for="password" class="form-label visually-hidden">{{t('password')}}</label>
|
||||
<input class="form-control custom-input" formControlName="password" name="password" autocomplete="current-password"
|
||||
id="password" type="password" [placeholder]="t('password')">
|
||||
</div>
|
||||
@if (isLoaded()) {
|
||||
@if (showPasswordLogin()) {
|
||||
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation">
|
||||
<div class="card-text">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label visually-hidden">{{t('username')}}</label>
|
||||
<input class="form-control custom-input" formControlName="username" id="username" autocomplete="username"
|
||||
type="text" autofocus [placeholder]="t('username')">
|
||||
</div>
|
||||
|
||||
<div class="mb-3 forgot-password">
|
||||
<a routerLink="/registration/reset-password">{{t('forgot-password')}}</a>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label for="password" class="form-label visually-hidden">{{t('password')}}</label>
|
||||
<input class="form-control custom-input" formControlName="password" name="password" autocomplete="current-password"
|
||||
id="password" type="password" [placeholder]="t('password')">
|
||||
</div>
|
||||
|
||||
<div class="sign-in">
|
||||
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting">{{t('submit')}}</button>
|
||||
<div class="mb-3 forgot-password">
|
||||
<a routerLink="/registration/reset-password">{{t('forgot-password')}}</a>
|
||||
</div>
|
||||
|
||||
<div class="sign-in">
|
||||
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting()">{{t('submit')}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-container>
|
||||
</form>
|
||||
}
|
||||
|
||||
@if (showOidcButton()) {
|
||||
<a
|
||||
class="btn btn-outline-primary mt-2 d-flex align-items-center gap-2"
|
||||
href="oidc/login"
|
||||
>
|
||||
<app-image height="36px" width="36px" [imageUrl]="'assets/icons/open-id-connect-logo.svg'" [styles]="{'object-fit': 'contains'}"></app-image>
|
||||
{{oidcConfig()?.providerName || t('oidc')}}
|
||||
</a>
|
||||
}
|
||||
}
|
||||
</ng-container>
|
||||
</app-splash-container>
|
||||
</ng-container>
|
||||
|
||||
@@ -45,4 +45,8 @@ a {
|
||||
.btn {
|
||||
font-family: var(--login-input-font-family);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component, computed,
|
||||
effect, inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import {ActivatedRoute, Router, RouterLink} from '@angular/router';
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
||||
import { ToastrService } from 'ngx-toastr';
|
||||
import { take } from 'rxjs/operators';
|
||||
import { AccountService } from '../../_services/account.service';
|
||||
import { MemberService } from '../../_services/member.service';
|
||||
import { NavService } from '../../_services/nav.service';
|
||||
import { NgIf } from '@angular/common';
|
||||
import { SplashContainerComponent } from '../_components/splash-container/splash-container.component';
|
||||
import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {translate, TranslocoDirective} from "@jsverse/transloco";
|
||||
import {environment} from "../../../environments/environment";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import { SettingsService } from 'src/app/admin/settings.service';
|
||||
import {OidcPublicConfig} from "../../admin/_models/oidc-config";
|
||||
|
||||
|
||||
@Component({
|
||||
@@ -17,90 +26,132 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
|
||||
templateUrl: './user-login.component.html',
|
||||
styleUrls: ['./user-login.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [SplashContainerComponent, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective]
|
||||
imports: [SplashContainerComponent, ReactiveFormsModule, RouterLink, TranslocoDirective, ImageComponent]
|
||||
})
|
||||
export class UserLoginComponent implements OnInit {
|
||||
|
||||
private readonly accountService = inject(AccountService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly memberService = inject(MemberService);
|
||||
private readonly toastr = inject(ToastrService);
|
||||
private readonly navService = inject(NavService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
protected readonly settingsService = inject(SettingsService);
|
||||
|
||||
baseUrl = environment.apiUrl;
|
||||
|
||||
loginForm: FormGroup = new FormGroup({
|
||||
username: new FormControl('', [Validators.required]),
|
||||
password: new FormControl('', [Validators.required, Validators.maxLength(256), Validators.minLength(6), Validators.pattern("^.{6,256}$")])
|
||||
});
|
||||
|
||||
/**
|
||||
* If there are no admins on the server, this will enable the registration to kick in.
|
||||
*/
|
||||
firstTimeFlow: boolean = true;
|
||||
/**
|
||||
* Used for first time the page loads to ensure no flashing
|
||||
*/
|
||||
isLoaded: boolean = false;
|
||||
isSubmitting = false;
|
||||
isLoaded = signal(false);
|
||||
isSubmitting = signal(false);
|
||||
/**
|
||||
* undefined until query params are read
|
||||
*/
|
||||
skipAutoLogin = signal<boolean | undefined>(undefined);
|
||||
/**
|
||||
* Display the login form, regardless if the password authentication is disabled (admins can still log in)
|
||||
* Set from query
|
||||
*/
|
||||
forceShowPasswordLogin = signal(false);
|
||||
oidcConfig = signal<OidcPublicConfig | undefined>(undefined);
|
||||
|
||||
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
|
||||
private toastr: ToastrService, private navService: NavService,
|
||||
private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
}
|
||||
/**
|
||||
* Display the login form
|
||||
*/
|
||||
showPasswordLogin = computed(() => {
|
||||
const loaded = this.isLoaded();
|
||||
const config = this.oidcConfig();
|
||||
const force = this.forceShowPasswordLogin();
|
||||
if (force) return true;
|
||||
|
||||
return loaded && config && !config.disablePasswordAuthentication;
|
||||
});
|
||||
showOidcButton = computed(() => {
|
||||
const config = this.oidcConfig();
|
||||
return config && config.enabled;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.navService.hideNavBar();
|
||||
this.navService.hideSideNav();
|
||||
|
||||
effect(() => {
|
||||
const skipAutoLogin = this.skipAutoLogin();
|
||||
const oidcConfig = this.oidcConfig();
|
||||
|
||||
if (!oidcConfig || skipAutoLogin === undefined) return;
|
||||
|
||||
if (oidcConfig.autoLogin && !skipAutoLogin) {
|
||||
window.location.href = '/oidc/login';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (user) {
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.router.navigateByUrl('/home');
|
||||
this.navService.handleLogin()
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.settingsService.getPublicOidcConfig().subscribe(config => {
|
||||
this.oidcConfig.set(config);
|
||||
});
|
||||
|
||||
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
|
||||
this.firstTimeFlow = !adminExists;
|
||||
|
||||
if (this.firstTimeFlow) {
|
||||
if (!adminExists) {
|
||||
this.router.navigateByUrl('registration/register');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoaded = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.isLoaded.set(true);
|
||||
});
|
||||
|
||||
this.route.queryParamMap.subscribe(params => {
|
||||
const val = params.get('apiKey');
|
||||
if (val != null && val.length > 0) {
|
||||
this.login(val);
|
||||
return;
|
||||
}
|
||||
|
||||
this.skipAutoLogin.set(params.get('skipAutoLogin') === 'true')
|
||||
this.forceShowPasswordLogin.set(params.get('forceShowPassword') === 'true');
|
||||
|
||||
const error = params.get('error');
|
||||
if (!error) return;
|
||||
|
||||
if (error.startsWith('errors.')) {
|
||||
this.toastr.error(translate(error));
|
||||
} else {
|
||||
this.toastr.error(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
login(apiKey: string = '') {
|
||||
const model = this.loginForm.getRawValue();
|
||||
model.apiKey = apiKey;
|
||||
this.isSubmitting = true;
|
||||
this.cdRef.markForCheck();
|
||||
this.accountService.login(model).subscribe(() => {
|
||||
this.loginForm.reset();
|
||||
this.navService.showNavBar();
|
||||
this.navService.showSideNav();
|
||||
this.isSubmitting.set(true);
|
||||
this.accountService.login(model).subscribe({
|
||||
next: () => {
|
||||
this.loginForm.reset();
|
||||
this.navService.handleLogin()
|
||||
|
||||
// Check if user came here from another url, else send to library route
|
||||
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
|
||||
if (pageResume && pageResume !== '/login') {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl(pageResume);
|
||||
} else {
|
||||
localStorage.setItem('kavita--auth-intersection-url', '');
|
||||
this.router.navigateByUrl('/home');
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.toastr.error(err.error);
|
||||
this.isSubmitting.set(false);
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
this.cdRef.markForCheck();
|
||||
}, err => {
|
||||
this.toastr.error(err.error);
|
||||
this.isSubmitting = false;
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.OpenIDConnect; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.OpenIDConnect) {
|
||||
<div class="col-xxl-6 col-12">
|
||||
<app-manage-open-idconnect></app-manage-open-idconnect>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@defer (when fragment === SettingsTabId.Email; prefetch on idle) {
|
||||
@if (fragment === SettingsTabId.Email) {
|
||||
<div class="col-xxl-6 col-12">
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
ManagePublicMetadataSettingsComponent
|
||||
} from "../../../admin/manage-public-metadata-settings/manage-public-metadata-settings.component";
|
||||
import {ImportMappingsComponent} from "../../../admin/import-mappings/import-mappings.component";
|
||||
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -96,6 +97,7 @@ import {ImportMappingsComponent} from "../../../admin/import-mappings/import-map
|
||||
ScrobblingHoldsComponent,
|
||||
ManageMetadataSettingsComponent,
|
||||
ManageReadingProfilesComponent,
|
||||
ManageOpenIDConnectComponent,
|
||||
ManagePublicMetadataSettingsComponent,
|
||||
ImportMappingsComponent
|
||||
],
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum SettingsTabId {
|
||||
|
||||
// Admin
|
||||
General = 'admin-general',
|
||||
OpenIDConnect = 'admin-oidc',
|
||||
Email = 'admin-email',
|
||||
Media = 'admin-media',
|
||||
Users = 'admin-users',
|
||||
@@ -127,6 +128,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.General, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.ManageMetadata, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.OpenIDConnect, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Media, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Email, [Role.Admin]),
|
||||
new SideNavItem(SettingsTabId.Users, [Role.Admin]),
|
||||
|
||||
+12
-12
@@ -259,20 +259,20 @@ export class ManageReadingProfilesComponent implements OnInit {
|
||||
private packData(): ReadingProfile {
|
||||
const data: ReadingProfile = this.readingProfileForm!.getRawValue();
|
||||
data.id = this.selectedProfile!.id;
|
||||
data.readingDirection = parseInt(data.readingDirection as unknown as string);
|
||||
data.scalingOption = parseInt(data.scalingOption as unknown as string);
|
||||
data.pageSplitOption = parseInt(data.pageSplitOption as unknown as string);
|
||||
data.readerMode = parseInt(data.readerMode as unknown as string);
|
||||
data.layoutMode = parseInt(data.layoutMode as unknown as string);
|
||||
data.disableWidthOverride = parseInt(data.disableWidthOverride as unknown as string);
|
||||
data.readingDirection = parseInt(data.readingDirection + '');
|
||||
data.scalingOption = parseInt(data.scalingOption + '');
|
||||
data.pageSplitOption = parseInt(data.pageSplitOption + '');
|
||||
data.readerMode = parseInt(data.readerMode + '');
|
||||
data.layoutMode = parseInt(data.layoutMode + '');
|
||||
data.disableWidthOverride = parseInt(data.disableWidthOverride + '');
|
||||
|
||||
data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection as unknown as string);
|
||||
data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle as unknown as string);
|
||||
data.bookReaderLayoutMode = parseInt(data.bookReaderLayoutMode as unknown as string);
|
||||
data.bookReaderReadingDirection = parseInt(data.bookReaderReadingDirection + '');
|
||||
data.bookReaderWritingStyle = parseInt(data.bookReaderWritingStyle + '');
|
||||
data.bookReaderLayoutMode = parseInt(data.bookReaderLayoutMode + '');
|
||||
|
||||
data.pdfTheme = parseInt(data.pdfTheme as unknown as string);
|
||||
data.pdfScrollMode = parseInt(data.pdfScrollMode as unknown as string);
|
||||
data.pdfSpreadMode = parseInt(data.pdfSpreadMode as unknown as string);
|
||||
data.pdfTheme = parseInt(data.pdfTheme + '');
|
||||
data.pdfScrollMode = parseInt(data.pdfScrollMode + '');
|
||||
data.pdfSpreadMode = parseInt(data.pdfSpreadMode + '');
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0"?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg height="512px" style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" width="512px" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><g id="_x32_39-openid"><g><path d="M234.849,419v6.623c-79.268-9.958-139.334-53.393-139.334-105.757 c0-39.313,33.873-73.595,84.485-92.511L178.023,180C88.892,202.497,26.001,256.607,26.001,319.866 c0,76.288,90.871,139.128,208.95,149.705l0.018-0.009V419H234.849z" style="fill:#B2B2B2;"/><polygon points="304.772,436.713 304.67,436.713 304.67,221.667 304.67,213.667 304.67,42.429 234.849,78.25 234.849,221.667 234.969,221.667 234.969,469.563 " style="fill:#F7931E;"/><path d="M485.999,291.938l-9.446-100.114l-35.938,20.331C415.087,196.649,382.5,177.5,340,177.261 l0.002,36.406v7.498c3.502,0.968,6.923,2.024,10.301,3.125c14.145,4.611,27.176,10.352,38.666,17.128l-37.786,21.254 L485.999,291.938z" style="fill:#B2B2B2;"/></g></g><g id="Layer_1"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -5,7 +5,69 @@
|
||||
"password": "{{common.password}}",
|
||||
"password-validation": "{{validation.password-validation}}",
|
||||
"forgot-password": "Forgot Password?",
|
||||
"submit": "Sign in"
|
||||
"submit": "Sign in",
|
||||
"oidc": "OpenID Connect"
|
||||
},
|
||||
|
||||
"oidc": {
|
||||
"title": "OIDC Callback",
|
||||
"login": "Back to login screen",
|
||||
"error-loading-info": "An error occurred loading OIDC info, contact your administrator",
|
||||
"timeout": "OIDC resolution has timed out or an error has occurred"
|
||||
},
|
||||
|
||||
"manage-oidc-connect": {
|
||||
"save": "{{common.save}}",
|
||||
"save-success": "Successfully updated your OIDC settings",
|
||||
"notice": "Notice",
|
||||
"restart-required": "Changing Authority or Client ID requires a manual restart of Kavita to take effect.",
|
||||
"provider-title": "Provider",
|
||||
"provider-tooltip": "Provider settings require you to manually click Save. Kavita must be configured as a confidential client and needs a redirect URL. See the <a href='https://wiki.kavitareader.com/guides/admin-settings/open-id-connect/' target='_blank' rel='noreferrer noopener'>wiki</a> for more details.",
|
||||
"behavior-title": "Behavior",
|
||||
"other-field-required": "{{validation.other-field-required}}",
|
||||
"invalid-uri": "{{validation.invalid-uri}}",
|
||||
"manual-save-label": "Changing provider settings requires a manual save",
|
||||
|
||||
"authority-label": "Authority",
|
||||
"authority-tooltip": "The URL to your OIDC provider",
|
||||
"client-id-label": "Client ID",
|
||||
"client-id-tooltip": "The ClientID set in your OIDC provider, can be anything",
|
||||
"secret-label": "Client secret",
|
||||
"secret-tooltip": "The secret as generated by your OIDC provider",
|
||||
"provision-accounts-label": "Provision accounts",
|
||||
"provision-accounts-tooltip": "Auto-create a new account when logging in via OIDC if it can't be matched to an existing one",
|
||||
"require-verified-email-label": "Require verified emails",
|
||||
"require-verified-email-tooltip": "Requires email verification when creating a new account or matching with an existing one. If a newly created account has a verified email, it will be automatically verified on Kavita's side.",
|
||||
"sync-user-settings-label": "Sync user settings with OIDC roles",
|
||||
"sync-user-settings-tooltip": "Users created via OIDC will be fully managed by the identity provider, including roles, library access, and age rating. If this option is disabled, users will not have access to any content after account creation. Refer to the documentation for more details.",
|
||||
"auto-login-label": "Auto login",
|
||||
"auto-login-tooltip": "Auto redirect to OIDC provider when opening the login screen",
|
||||
"disable-password-authentication-label": "Disable password authentication",
|
||||
"disable-password-authentication-tooltip": "Users with the admin role can bypass this restriction",
|
||||
"provider-name-label": "Provider name",
|
||||
"provider-name-tooltip": "Name shown on the login screen",
|
||||
|
||||
"defaults-title": "Defaults",
|
||||
"defaults-requirement": "The following settings are used when a user is registered via OIDC while SyncUserSettings is turned off",
|
||||
"default-include-unknowns-label": "Include unknowns",
|
||||
"default-include-unknowns-tooltip": "Include unknown age ratings",
|
||||
"default-age-restriction-label": "Age rating",
|
||||
"default-age-restriction-tooltip": "Maximum age rating shown to new users",
|
||||
"no-restriction": "{{restriction-selector.no-restriction}}",
|
||||
|
||||
"advanced-title": "Advanced settings",
|
||||
"advanced-tooltip": "Only modify these options if you understand their impact.",
|
||||
"roles-prefix-label": "Roles prefix",
|
||||
"roles-prefix-tooltip": "Kavita will only consider roles that start with this prefix.",
|
||||
"roles-claim-label": "Roles claim",
|
||||
"roles-claim-tooltip": "The claim Kavita will use to extract roles. Leave blank to use the default.",
|
||||
"custom-scopes-label": "Custom scopes",
|
||||
"custom-scopes-tooltip": "Additional scopes to request from your OIDC provider during login. Separate multiple scopes with commas. Incorrect values may prevent login."
|
||||
},
|
||||
|
||||
"identity-provider-pipe": {
|
||||
"kavita": "Kavita",
|
||||
"oidc": "OpenID Connect"
|
||||
},
|
||||
|
||||
"dashboard": {
|
||||
@@ -28,7 +90,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 SyncUsers setting is enabled, any changes made to this account may be overwritten.",
|
||||
"oidc-managed": "This user is managed via OIDC. Please contact your OIDC administrator to request any changes.",
|
||||
"identity-provider": "Identity provider",
|
||||
"identity-provider-tooltip": "Kavita users are never synced with OIDC accounts."
|
||||
},
|
||||
|
||||
"user-scrobble-history": {
|
||||
@@ -1714,6 +1781,7 @@
|
||||
"import-section-title": "Import",
|
||||
"kavitaplus-section-title": "Kavita+",
|
||||
"admin-general": "General",
|
||||
"admin-oidc": "OpenID Connect",
|
||||
"admin-users": "Users",
|
||||
"admin-libraries": "Libraries",
|
||||
"admin-media": "Media",
|
||||
@@ -2331,7 +2399,7 @@
|
||||
"asin-tooltip": "https://www.amazon.com/stores/J.K.-Rowling/author/{ASIN}",
|
||||
"invalid-asin": "ASIN must be a valid ISBN-10 or ISBN-13 format",
|
||||
"description-label": "Description",
|
||||
"required-field": "{{validations.required-field}}",
|
||||
"required-field": "{{validation.required-field}}",
|
||||
"cover-image-description": "{{edit-series-modal.cover-image-description}}",
|
||||
"cover-image-description-extra": "Alternatively you can download a cover from CoversDB if available.",
|
||||
"save": "{{common.save}}",
|
||||
@@ -2483,6 +2551,19 @@
|
||||
"import-fields": {
|
||||
"non-unique-age-ratings": "Age rating mapping keys aren't unique, please correct your import file",
|
||||
"non-unique-fields": "Field mappings do not have a unique id, please correct your import file"
|
||||
},
|
||||
"oidc": {
|
||||
"missing-external-id": "OIDC did not return a valid identifier",
|
||||
"missing-email": "OIDC did not return a valid email",
|
||||
"email-not-verified": "Your email must be verified to allow login via OIDC",
|
||||
"no-account": "No matching account found",
|
||||
"disabled-account": "This account is disabled, please contact an administrator",
|
||||
"creating-user": "Failed to create a new user, please contact an administrator",
|
||||
"role-not-assigned": "You do not have the required roles assigned to access this application",
|
||||
"failed-to-update-email": "Failed to update email",
|
||||
"failed-to-update-username": "Failed to update username",
|
||||
"email-in-use": "Email already in use by another account",
|
||||
"syncing-user": "Failed to sync your account from OIDC, please contact an administrator"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3038,9 +3119,11 @@
|
||||
|
||||
"validation": {
|
||||
"required-field": "This field is required",
|
||||
"other-field-required": "{{name}} is required when {{other}} is set",
|
||||
"valid-email": "This must be a valid email",
|
||||
"password-validation": "Password must be between 6 and 256 characters in length",
|
||||
"year-validation": "This must be a valid year greater than 1000 and 4 characters long"
|
||||
"year-validation": "This must be a valid year greater than 1000 and 4 characters long",
|
||||
"invalid-uri": "The provided uri is invalid"
|
||||
},
|
||||
|
||||
"entity-type": {
|
||||
|
||||
@@ -6,8 +6,8 @@ const IP = 'localhost';
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://' + IP + ':5000/api/',
|
||||
hubUrl: 'http://'+ IP + ':5000/hubs/',
|
||||
apiUrl: 'http://' + IP + ':4200/api/',
|
||||
hubUrl: 'http://'+ IP + ':4200/hubs/',
|
||||
buyLink: 'https://buy.stripe.com/test_9AQ5mi058h1PcIo3cf?prefilled_promo_code=FREETRIAL',
|
||||
manageLink: 'https://billing.stripe.com/p/login/test_14kfZocuh6Tz5ag7ss'
|
||||
};
|
||||
|
||||
+35
-32
@@ -1,52 +1,27 @@
|
||||
/// <reference types="@angular/localize" />
|
||||
import {APP_INITIALIZER, ApplicationConfig, importProvidersFrom,} from '@angular/core';
|
||||
import {ApplicationConfig, importProvidersFrom, inject, provideAppInitializer,} from '@angular/core';
|
||||
import {AppComponent} from './app/app.component';
|
||||
import {NgCircleProgressModule} from 'ng-circle-progress';
|
||||
import {ToastrModule} from 'ngx-toastr';
|
||||
import {ToastrModule, ToastrService} from 'ngx-toastr';
|
||||
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import {AppRoutingModule} from './app/app-routing.module';
|
||||
import {bootstrapApplication, BrowserModule, Title} from '@angular/platform-browser';
|
||||
import {JwtInterceptor} from './app/_interceptors/jwt.interceptor';
|
||||
import {ErrorInterceptor} from './app/_interceptors/error.interceptor';
|
||||
import {HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
|
||||
import {provideTransloco, TranslocoConfig, TranslocoService} from "@jsverse/transloco";
|
||||
import {provideTransloco, translate, TranslocoConfig, TranslocoService} from "@jsverse/transloco";
|
||||
import {environment} from "./environments/environment";
|
||||
import {AccountService} from "./app/_services/account.service";
|
||||
import {switchMap} from "rxjs";
|
||||
import {catchError, filter, firstValueFrom, Observable, of, switchMap, take, tap, timeout} from "rxjs";
|
||||
import {provideTranslocoLocale} from "@jsverse/transloco-locale";
|
||||
import {LazyLoadImageModule} from "ng-lazyload-image";
|
||||
import {getSaver, SAVER} from "./app/_providers/saver.provider";
|
||||
import {distinctUntilChanged} from "rxjs/operators";
|
||||
import {APP_BASE_HREF, PlatformLocation} from "@angular/common";
|
||||
import {provideTranslocoPersistTranslations} from '@jsverse/transloco-persist-translations';
|
||||
import {HttpLoader} from "./httpLoader";
|
||||
|
||||
import {SettingsService} from "./app/admin/settings.service";
|
||||
const disableAnimations = !('animate' in document.documentElement);
|
||||
|
||||
export function preloadUser(userService: AccountService, transloco: TranslocoService) {
|
||||
return function() {
|
||||
return userService.currentUser$.pipe(distinctUntilChanged(), switchMap((user) => {
|
||||
if (user && user.preferences.locale) {
|
||||
transloco.setActiveLang(user.preferences.locale);
|
||||
return transloco.load(user.preferences.locale)
|
||||
}
|
||||
|
||||
// If no user or locale is available, fallback to the default language ('en')
|
||||
const localStorageLocale = localStorage.getItem(AccountService.localeKey) || 'en';
|
||||
transloco.setActiveLang(localStorageLocale);
|
||||
return transloco.load(localStorageLocale);
|
||||
})).subscribe();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const preLoad = {
|
||||
provide: APP_INITIALIZER,
|
||||
multi: true,
|
||||
useFactory: preloadUser,
|
||||
deps: [AccountService, TranslocoService]
|
||||
};
|
||||
|
||||
function transformLanguageCodes(arr: Array<string>) {
|
||||
const transformedArray: Array<string> = [];
|
||||
|
||||
@@ -112,6 +87,34 @@ function getBaseHref(platformLocation: PlatformLocation): string {
|
||||
return platformLocation.getBaseHrefFromDOM();
|
||||
}
|
||||
|
||||
|
||||
function loadUserLocale(transloco: TranslocoService, accountService: AccountService) {
|
||||
const user = accountService.currentUserSignal();
|
||||
const locale = user?.preferences?.locale || localStorage.getItem(AccountService.localeKey) || 'en';
|
||||
|
||||
transloco.setActiveLang(locale);
|
||||
return transloco.load(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup user from localstorage
|
||||
*/
|
||||
function bootstrapUser() {
|
||||
const accountService = inject(AccountService);
|
||||
const transloco = inject(TranslocoService);
|
||||
|
||||
return firstValueFrom(accountService.isOidcAuthenticated().pipe(
|
||||
switchMap((isOidc)=> isOidc ? accountService.getAccount() : of(null)),
|
||||
catchError(() => of(null)),
|
||||
tap(user => {
|
||||
if (!user) {
|
||||
accountService.setCurrentUser(accountService.getUserFromLocalStorage());
|
||||
}
|
||||
}),
|
||||
switchMap(() => loadUserLocale(transloco, accountService)),
|
||||
));
|
||||
}
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
importProvidersFrom(BrowserModule,
|
||||
@@ -138,7 +141,6 @@ bootstrapApplication(AppComponent, {
|
||||
}),
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true },
|
||||
{ provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true },
|
||||
preLoad,
|
||||
Title,
|
||||
{ provide: SAVER, useFactory: getSaver },
|
||||
{
|
||||
@@ -146,7 +148,8 @@ bootstrapApplication(AppComponent, {
|
||||
useFactory: getBaseHref,
|
||||
deps: [PlatformLocation]
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi())
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideAppInitializer(() => bootstrapUser()),
|
||||
]
|
||||
} as ApplicationConfig)
|
||||
.catch(err => console.error(err));
|
||||
|
||||
Reference in New Issue
Block a user