POC oidc login
This commit is contained in:
parent
6288d89651
commit
df9d970a42
48 changed files with 5009 additions and 96 deletions
|
|
@ -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}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
9
UI/Web/src/app/_routes/oidc-routing.module.ts
Normal file
9
UI/Web/src/app/_routes/oidc-routing.module.ts
Normal 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,
|
||||
}
|
||||
];
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
127
UI/Web/src/app/_services/oidc.service.ts
Normal file
127
UI/Web/src/app/_services/oidc.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
9
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
9
UI/Web/src/app/admin/_models/oidc-config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
export interface OidcConfig {
|
||||
authority: string;
|
||||
clientId: string;
|
||||
provisionAccounts: boolean;
|
||||
requireVerifiedEmail: boolean;
|
||||
provisionUserSettings: boolean;
|
||||
autoLogin: 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.custom-position {
|
||||
right: 5px;
|
||||
top: -42px;
|
||||
}
|
||||
|
|
@ -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}}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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'},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.invalid-feedback {
|
||||
display: inherit;
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue