POC oidc login

This commit is contained in:
Amelia 2025-05-24 13:57:06 +02:00
parent 6288d89651
commit df9d970a42
48 changed files with 5009 additions and 96 deletions

View file

@ -16,7 +16,7 @@ export class JwtInterceptor implements HttpInterceptor {
if (user) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${user.token}`
Authorization: `Bearer ${user.oidcToken ?? user.token}`
}
});
}

View file

@ -4,6 +4,9 @@ import {Preferences} from './preferences/preferences';
// This interface is only used for login and storing/retrieving JWT from local storage
export interface User {
username: string;
// This is set by the oidc service, will always take precedence over the Kavita generated token
// When set, the refresh logic for the Kavita token will not run
oidcToken: string;
token: string;
refreshToken: string;
roles: string[];

View file

@ -0,0 +1,9 @@
import {Routes} from "@angular/router";
import {OidcCallbackComponent} from "../registration/oidc-callback/oidc-callback.component";
export const routes: Routes = [
{
path: 'callback',
component: OidcCallbackComponent,
}
];

View file

@ -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';
@ -90,6 +90,10 @@ export class AccountService {
});
}
oidcEnabled() {
return this.httpClient.get<boolean>(this.baseUrl + "oidc/enabled");
}
canInvokeAction(user: User, action: Action) {
const isAdmin = this.hasAdminRole(user);
const canDownload = this.hasDownloadRole(user);
@ -167,6 +171,22 @@ export class AccountService {
);
}
loginByToken(token: string) {
const headers = new HttpHeaders({
"Authorization": `Bearer ${token}`
})
return this.httpClient.get<User>(this.baseUrl + 'account', {headers}).pipe(
tap((response: User) => {
const user = response;
if (user) {
user.oidcToken = token;
this.setCurrentUser(user);
}
}),
takeUntilDestroyed(this.destroyRef)
);
}
setCurrentUser(user?: User, refreshConnections = true) {
const isSameUser = this.currentUser === user;
@ -202,7 +222,10 @@ export class AccountService {
this.messageHub.createHubConnection(this.currentUser);
this.licenseService.hasValidLicense().subscribe();
}
this.startRefreshTokenTimer();
// oidc handles refreshing itself
if (!this.currentUser.oidcToken) {
this.startRefreshTokenTimer();
}
}
}

View file

@ -160,7 +160,7 @@ export class MessageHubService {
createHubConnection(user: User) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'messages', {
accessTokenFactory: () => user.token
accessTokenFactory: () => user.oidcToken ?? user.token
})
.withAutomaticReconnect()
//.withStatefulReconnect() // Requires signalr@8.0

View file

@ -0,0 +1,127 @@
import {DestroyRef, Injectable} from '@angular/core';
import {OAuthService} from "angular-oauth2-oidc";
import {BehaviorSubject, from} from "rxjs";
import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {OidcConfig} from "../admin/_models/oidc-config";
import {AccountService} from "./account.service";
import {NavService} from "./nav.service";
import {Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {take} from "rxjs/operators";
@Injectable({
providedIn: 'root'
})
export class OidcService {
/*
TODO: Further cleanup, nicer handling for the user
See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards
Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts
*/
baseUrl = environment.apiUrl;
settingsSource = new BehaviorSubject<OidcConfig | null>(null);
settings$ = this.settingsSource.asObservable();
constructor(
private oauth2: OAuthService,
private httpClient: HttpClient,
private accountService: AccountService,
private navService: NavService,
private router: Router,
private destroyRef: DestroyRef,
) {
this.config().subscribe(oidcSetting => {
if (!oidcSetting.authority) {
return
}
this.oauth2.configure({
issuer: oidcSetting.authority,
clientId: oidcSetting.clientId,
requireHttps: oidcSetting.authority.startsWith("https://"),
redirectUri: window.location.origin + "/oidc/callback",
postLogoutRedirectUri: window.location.origin + "/login",
showDebugInformation: true,
responseType: 'code',
scope: "openid profile email roles offline_access",
});
this.settingsSource.next(oidcSetting);
this.oauth2.setupAutomaticSilentRefresh();
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
if (event.type !== "token_refreshed") return;
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (!user) return; // Don't update tokens when we're not logged in. But what's going on?
// TODO: Do we need to refresh the SignalR connection here?
user.oidcToken = this.token;
});
});
from(this.oauth2.loadDiscoveryDocumentAndTryLogin()).subscribe({
next: success => {
if (!success) return;
this.tryLogin();
},
error: error => {
console.log(error);
}
});
})
}
private tryLogin() {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) return;
if (this.token) {
this.accountService.loginByToken(this.token).subscribe({
next: _ => {
this.doLogin();
}
});
}
});
}
oidcLogin() {
this.oauth2.initLoginFlow();
}
config() {
return this.httpClient.get<OidcConfig>(this.baseUrl + "oidc/config");
}
get token() {
return this.oauth2.getAccessToken();
}
logout() {
this.oauth2.logOut();
}
private doLogin() {
this.navService.showNavBar();
this.navService.showSideNav();
// 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');
}
}
}

View file

@ -0,0 +1,9 @@
export interface OidcConfig {
authority: string;
clientId: string;
provisionAccounts: boolean;
requireVerifiedEmail: boolean;
provisionUserSettings: boolean;
autoLogin: boolean;
}

View file

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

View file

@ -0,0 +1,111 @@
<ng-container *transloco="let t; prefix:'oidc.settings'">
<div class="position-relative">
<button type="button" class="btn btn-primary position-absolute custom-position" (click)="save()">{{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')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if (settingsForm.get('authority'); as formControl) {
<app-setting-item [title]="t('authority')" [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">
</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('clientId')" [subtitle]="t('clientId-tooltip')">
<ng-template #view>
{{formControl.value}}
</ng-template>
<ng-template #edit>
<input id="oid-clientId" aria-describedby="oidc-clientId-validations" class="form-control"
formControlName="clientId" type="text"
[class.is-invalid]="formControl.invalid && !formControl.untouched">
@if (settingsForm.dirty || !settingsForm.untouched) {
<div id="oidc-clientId-validations" class="invalid-feedback">
@if (formControl.errors?.requiredIf) {
<div>{{t('field-required', {name: 'clientId', other: formControl.errors?.requiredIf.other})}}</div>
}
</div>
}
</ng-template>
</app-setting-item>
}
</div>
</ng-container>
<div class="setting-section-break"></div>
<h4>{{t('behaviour')}}</h4>
<ng-container>
<div class="row g-0 mt-4 mb-4">
@if(settingsForm.get('provisionAccounts'); as formControl) {
<app-setting-switch [title]="t('provisionAccounts')" [subtitle]="t('provisionAccounts-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provisionAccounts" 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('requireVerifiedEmail')" [subtitle]="t('requireVerifiedEmail-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="requireVerifiedEmail" 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('provisionUserSettings'); as formControl) {
<app-setting-switch [title]="t('provisionUserSettings')" [subtitle]="t('provisionUserSettings-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="provisionUserSettings" type="checkbox" class="form-check-input" formControlName="provisionUserSettings">
</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('autoLogin')" [subtitle]="t('autoLogin-tooltip')">
<ng-template #switch>
<div class="form-check form-switch float-end">
<input id="autoLogin" type="checkbox" class="form-check-input" formControlName="autoLogin">
</div>
</ng-template>
</app-setting-switch>
}
</div>
</ng-container>
</form>
</ng-container>

View file

@ -0,0 +1,8 @@
.invalid-feedback {
display: inherit;
}
.custom-position {
right: 5px;
top: -42px;
}

View file

@ -0,0 +1,92 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {TranslocoDirective} from "@jsverse/transloco";
import {ServerSettings} from "../_models/server-settings";
import {
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";
@Component({
selector: 'app-manage-open-idconnect',
imports: [
TranslocoDirective,
ReactiveFormsModule,
SettingItemComponent,
SettingSwitchComponent
],
templateUrl: './manage-open-idconnect.component.html',
styleUrl: './manage-open-idconnect.component.scss'
})
export class ManageOpenIDConnectComponent implements OnInit {
serverSettings!: ServerSettings;
oidcSettings!: OidcConfig;
settingsForm: FormGroup = new FormGroup({});
constructor(
private settingsService: SettingsService,
private cdRef: ChangeDetectorRef,
) {
}
ngOnInit(): void {
this.settingsService.getServerSettings().subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings = this.serverSettings.oidcConfig;
// TODO: Validator for authority, /.well-known/openid-configuration endpoint must be reachable
this.settingsForm.addControl('authority', new FormControl(this.oidcSettings.authority, []));
this.settingsForm.addControl('clientId', new FormControl(this.oidcSettings.clientId, [this.requiredIf('authority')]));
this.settingsForm.addControl('provisionAccounts', new FormControl(this.oidcSettings.provisionAccounts, []));
this.settingsForm.addControl('requireVerifiedEmail', new FormControl(this.oidcSettings.requireVerifiedEmail, []));
this.settingsForm.addControl('provisionUserSettings', new FormControl(this.oidcSettings.provisionUserSettings, []));
this.settingsForm.addControl('autoLogin', new FormControl(this.oidcSettings.autoLogin, []));
this.cdRef.markForCheck();
}
})
}
save() {
const data = this.settingsForm.getRawValue();
const newSettings = Object.assign({}, this.serverSettings);
newSettings.oidcConfig = data as OidcConfig;
this.settingsService.updateServerSettings(newSettings).subscribe({
next: data => {
this.serverSettings = data;
this.oidcSettings = data.oidcConfig;
this.cdRef.markForCheck();
},
error: error => {
console.error(error);
}
})
}
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}}
}
}
}

View file

@ -104,6 +104,10 @@ const routes: Routes = [
path: 'login',
loadChildren: () => import('./_routes/registration.router.module').then(m => m.routes) // TODO: Refactor so we just use /registration/login going forward
},
{
path: 'oidc',
loadChildren: () => import('./_routes/oidc-routing.module').then(m => m.routes)
},
{path: 'libraries', pathMatch: 'full', redirectTo: 'home'},
{path: '**', pathMatch: 'prefix', redirectTo: 'home'},
{path: '**', pathMatch: 'full', redirectTo: 'home'},

View file

@ -25,6 +25,7 @@ import {TranslocoService} from "@jsverse/transloco";
import {VersionService} from "./_services/version.service";
import {LicenseService} from "./_services/license.service";
import {LocalizationService} from "./_services/localization.service";
import {OidcService} from "./_services/oidc.service";
@Component({
selector: 'app-root',
@ -51,6 +52,7 @@ export class AppComponent implements OnInit {
private readonly document = inject(DOCUMENT);
private readonly translocoService = inject(TranslocoService);
private readonly versionService = inject(VersionService); // Needs to be injected to run background job
private readonly oidcService = inject(OidcService); // Needed to auto login
private readonly licenseService = inject(LicenseService);
private readonly localizationService = inject(LocalizationService);

View file

@ -47,6 +47,7 @@ import {SettingsTabId} from "../../../sidenav/preference-nav/preference-nav.comp
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {WikiLink} from "../../../_models/wiki";
import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component";
import {OidcService} from "../../../_services/oidc.service";
@Component({
selector: 'app-nav-header',
@ -64,6 +65,7 @@ export class NavHeaderComponent implements OnInit {
private readonly searchService = inject(SearchService);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected readonly navService = inject(NavService);
@ -135,6 +137,7 @@ export class NavHeaderComponent implements OnInit {
this.accountService.logout();
this.navService.hideNavBar();
this.navService.hideSideNav();
this.oidcService.logout();
this.router.navigateByUrl('/login');
}

View file

@ -0,0 +1,15 @@
<ng-container *transloco="let t; read: 'oidc'">
<app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body>
@if (error.length > 0) {
<div class="invalid-feedback mb-2">
{{t(error)}}
</div>
}
<button class="btn btn-outline-primary" (click)="goToLogin()">{{t('login')}}</button>
</ng-container>
</app-splash-container>
</ng-container>

View file

@ -0,0 +1,3 @@
.invalid-feedback {
display: inherit;
}

View file

@ -0,0 +1,48 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core';
import {SplashContainerComponent} from "../_components/splash-container/splash-container.component";
import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service";
import {Router} from "@angular/router";
import {NavService} from "../../_services/nav.service";
import {take} from "rxjs/operators";
import {OidcService} from "../../_services/oidc.service";
@Component({
selector: 'app-oidc-callback',
imports: [
SplashContainerComponent,
TranslocoDirective
],
templateUrl: './oidc-callback.component.html',
styleUrl: './oidc-callback.component.scss'
})
export class OidcCallbackComponent implements OnInit{
error: string = '';
constructor(
private accountService: AccountService,
private router: Router,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
private oidcService: OidcService,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
}
ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.navService.showNavBar();
this.navService.showSideNav();
this.router.navigateByUrl('/home');
this.cdRef.markForCheck();
}
});
}
goToLogin() {
this.router.navigateByUrl('/login');
}
}

View file

@ -26,6 +26,11 @@
</div>
</div>
</form>
@if (oidcEnabled) {
<button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.oidcLogin()">{{t('oidc')}}</button>
}
</ng-container>
</ng-container>
</app-splash-container>

View file

@ -1,7 +1,7 @@
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} 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 {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { AccountService } from '../../_services/account.service';
@ -10,6 +10,9 @@ 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 {environment} from "../../../environments/environment";
import {OidcService} from "../../_services/oidc.service";
import {forkJoin} from "rxjs";
@Component({
@ -17,10 +20,12 @@ 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, NgIf, ReactiveFormsModule, RouterLink, TranslocoDirective, NgbTooltip]
})
export class UserLoginComponent implements OnInit {
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}$")])
@ -35,10 +40,18 @@ export class UserLoginComponent implements OnInit {
*/
isLoaded: boolean = false;
isSubmitting = false;
oidcEnabled = false;
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
private toastr: ToastrService, private navService: NavService,
private readonly cdRef: ChangeDetectorRef, private route: ActivatedRoute) {
constructor(
private accountService: AccountService,
private router: Router,
private memberService: MemberService,
private toastr: ToastrService,
private navService: NavService,
private readonly cdRef: ChangeDetectorRef,
private route: ActivatedRoute,
protected oidcService: OidcService,
) {
this.navService.hideNavBar();
this.navService.hideSideNav();
}
@ -71,6 +84,18 @@ export class UserLoginComponent implements OnInit {
if (val != null && val.length > 0) {
this.login(val);
}
const skipAutoLogin = params.get('skipAutoLogin') === 'true';
this.oidcService.settings$.subscribe(cfg => {
if (!cfg) return;
this.oidcEnabled = cfg.authority !== "";
this.cdRef.markForCheck();
if (cfg.autoLogin && !skipAutoLogin) {
this.oidcService.oidcLogin()
}
});
});
}
@ -83,18 +108,8 @@ export class UserLoginComponent implements OnInit {
this.cdRef.markForCheck();
this.accountService.login(model).subscribe(() => {
this.loginForm.reset();
this.navService.showNavBar();
this.navService.showSideNav();
this.doLogin()
// 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 = false;
this.cdRef.markForCheck();
}, err => {
@ -103,4 +118,19 @@ export class UserLoginComponent implements OnInit {
this.cdRef.markForCheck();
});
}
private doLogin() {
this.navService.showNavBar();
this.navService.showSideNav();
// 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');
}
}
}

View file

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

View file

@ -52,43 +52,45 @@ import {ScrobblingHoldsComponent} from "../../../user-settings/user-holds/scrobb
import {
ManageMetadataSettingsComponent
} from "../../../admin/manage-metadata-settings/manage-metadata-settings.component";
import {ManageOpenIDConnectComponent} from "../../../admin/manage-open-idconnect/manage-open-idconnect.component";
@Component({
selector: 'app-settings',
imports: [
ChangeAgeRestrictionComponent,
ChangeEmailComponent,
ChangePasswordComponent,
ManageDevicesComponent,
ManageOpdsComponent,
ManageScrobblingProvidersComponent,
ManageUserPreferencesComponent,
SideNavCompanionBarComponent,
ThemeManagerComponent,
TranslocoDirective,
UserStatsComponent,
AsyncPipe,
LicenseComponent,
ManageEmailSettingsComponent,
ManageLibraryComponent,
ManageMediaSettingsComponent,
ManageSettingsComponent,
ManageSystemComponent,
ManageTasksSettingsComponent,
ManageUsersComponent,
ServerStatsComponent,
SettingFragmentPipe,
ManageScrobblingComponent,
ManageMediaIssuesComponent,
ManageCustomizationComponent,
ImportMalCollectionComponent,
ImportCblComponent,
ManageMatchedMetadataComponent,
ManageUserTokensComponent,
EmailHistoryComponent,
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent
],
imports: [
ChangeAgeRestrictionComponent,
ChangeEmailComponent,
ChangePasswordComponent,
ManageDevicesComponent,
ManageOpdsComponent,
ManageScrobblingProvidersComponent,
ManageUserPreferencesComponent,
SideNavCompanionBarComponent,
ThemeManagerComponent,
TranslocoDirective,
UserStatsComponent,
AsyncPipe,
LicenseComponent,
ManageEmailSettingsComponent,
ManageLibraryComponent,
ManageMediaSettingsComponent,
ManageSettingsComponent,
ManageSystemComponent,
ManageTasksSettingsComponent,
ManageUsersComponent,
ServerStatsComponent,
SettingFragmentPipe,
ManageScrobblingComponent,
ManageMediaIssuesComponent,
ManageCustomizationComponent,
ImportMalCollectionComponent,
ImportCblComponent,
ManageMatchedMetadataComponent,
ManageUserTokensComponent,
EmailHistoryComponent,
ScrobblingHoldsComponent,
ManageMetadataSettingsComponent,
ManageOpenIDConnectComponent
],
templateUrl: './settings.component.html',
styleUrl: './settings.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush

View file

@ -21,6 +21,7 @@ export enum SettingsTabId {
// Admin
General = 'admin-general',
OpenIDConnect = 'admin-oidc',
Email = 'admin-email',
Media = 'admin-media',
Users = 'admin-users',
@ -122,6 +123,7 @@ export class PreferenceNavComponent implements AfterViewInit {
title: 'server-section-title',
children: [
new SideNavItem(SettingsTabId.General, [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]),

View file

@ -5,7 +5,42 @@
"password": "{{common.password}}",
"password-validation": "{{validation.password-validation}}",
"forgot-password": "Forgot Password?",
"submit": "Sign in"
"submit": "Sign in",
"oidc": "Log in with OpenID Connect",
"oidc-tooltip": "This will connect you to an external site"
},
"oidc": {
"title": "OpenID Connect Callback",
"login": "Back to login screen",
"errors": {
"missing-external-id": "OpenID Connect provider did not return a valid identifier",
"missing-email": "OpenID Connect provider did not return a valid email",
"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"
},
"settings": {
"save": "{{common.save}}",
"notice": "Notice",
"restart-required": "Changing OpenID Connect settings requires a restart",
"provider": "Provider",
"behaviour": "Behaviour",
"field-required": "{{name}} is required when {{other}} is set",
"authority": "Authority",
"authority-tooltip": "The URL to your OpenID Connect provider",
"clientId": "Client ID",
"clientId-tooltip": "The ClientID set in your OIDC provider, can be anything",
"provisionAccounts": "Provision accounts",
"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",
"autoLogin": "Auto login",
"autoLogin-tooltip": "Auto redirect to OpenID Connect provider when opening the login screen"
}
},
"dashboard": {
@ -1698,6 +1733,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",

View file

@ -20,6 +20,7 @@ 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 {provideOAuthClient} from "angular-oauth2-oidc";
const disableAnimations = !('animate' in document.documentElement);
@ -146,6 +147,7 @@ bootstrapApplication(AppComponent, {
useFactory: getBaseHref,
deps: [PlatformLocation]
},
provideOAuthClient(),
provideHttpClient(withInterceptorsFromDi())
]
} as ApplicationConfig)