Cleanup, nicer flow

This commit is contained in:
Amelia 2025-06-29 20:16:17 +02:00
parent 465723fedf
commit 0b64ea1622
No known key found for this signature in database
GPG key ID: D6D0ECE365407EAA
15 changed files with 184 additions and 179 deletions

View file

@ -1,6 +1,7 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using API.Data; using API.Data;
using API.DTOs.Settings; using API.DTOs.Settings;
using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -8,16 +9,14 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers; namespace API.Controllers;
[AllowAnonymous] [AllowAnonymous]
public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork): BaseApiController public class OidcController(ILogger<OidcController> logger, IUnitOfWork unitOfWork, IMapper mapper): BaseApiController
{ {
// TODO: Decide what we want to expose here, not really anything useful in it. But the discussion is needed
// Public endpoint
[HttpGet("config")] [HttpGet("config")]
public async Task<ActionResult<OidcConfigDto>> GetOidcConfig() public async Task<ActionResult<OidcPublicConfigDto>> GetOidcConfig()
{ {
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settings.OidcConfig); return Ok(mapper.Map<OidcPublicConfigDto>(settings.OidcConfig));
} }
} }

View file

@ -0,0 +1,12 @@
#nullable enable
namespace API.DTOs.Settings;
public sealed record OidcPublicConfigDto
{
/// <inheritdoc cref="OidcConfigDto.Authority"/>
public string? Authority { get; set; }
/// <inheritdoc cref="OidcConfigDto.ClientId"/>
public string? ClientId { get; set; }
/// <inheritdoc cref="OidcConfigDto.AutoLogin"/>
public bool AutoLogin { get; set; }
}

View file

@ -123,7 +123,7 @@ public static class IdentityServiceExtensions
options.Events = new JwtBearerEvents options.Events = new JwtBearerEvents
{ {
OnMessageReceived = SetTokenFromQuery, OnMessageReceived = SetTokenFromQuery,
OnTokenValidated = OidcClaimsPrincipalConverter OnTokenValidated = OidcClaimsPrincipalConverter,
}; };
}); });
} }

View file

@ -386,7 +386,6 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>())) .ForMember(dest => dest.Overrides, opt => opt.MapFrom(src => src.Overrides ?? new List<MetadataSettingField>()))
.ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>())); .ForMember(dest => dest.AgeRatingMappings, opt => opt.MapFrom(src => src.AgeRatingMappings ?? new Dictionary<string, AgeRating>()));
CreateMap<OidcConfigDto, OidcPublicConfigDto>();
} }
} }

View file

@ -41,21 +41,21 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier); var externalId = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(externalId)) if (string.IsNullOrEmpty(externalId))
throw new KavitaException("oidc.errors.missing-external-id"); throw new KavitaException("errors.oidc.missing-external-id");
var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences); var user = await unitOfWork.UserRepository.GetByExternalId(externalId, AppUserIncludes.UserPreferences);
if (user != null) if (user != null)
{ {
// await ProvisionUserSettings(settings, principal, user); //await SyncUserSettings(settings, principal, user);
return user; return user;
} }
var email = principal.FindFirstValue(ClaimTypes.Email); var email = principal.FindFirstValue(ClaimTypes.Email);
if (string.IsNullOrEmpty(email)) if (string.IsNullOrEmpty(email))
throw new KavitaException("oidc.errors.missing-email"); throw new KavitaException("errors.oidc.missing-email");
if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail()) if (settings.RequireVerifiedEmail && !principal.HasVerifiedEmail())
throw new KavitaException("oidc.errors.email-not-verified"); throw new KavitaException("errors.oidc.email-not-verified");
user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences) user = await unitOfWork.UserRepository.GetUserByEmailAsync(email, AppUserIncludes.UserPreferences)
@ -64,11 +64,11 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
user.ExternalId = externalId; user.ExternalId = externalId;
// await ProvisionUserSettings(settings, principal, user); //await SyncUserSettings(settings, principal, user);
var roles = await userManager.GetRolesAsync(user); var roles = await userManager.GetRolesAsync(user);
if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole)) if (roles.Count > 0 && !roles.Contains(PolicyConstants.LoginRole))
throw new KavitaException("oidc.errors.disabled-account"); throw new KavitaException("errors.oidc.disabled-account");
return user; return user;
} }
@ -101,7 +101,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
{ {
logger.LogError("Failed to create new user from OIDC: {Errors}", logger.LogError("Failed to create new user from OIDC: {Errors}",
res.Errors.Select(x => x.Description).ToString()); res.Errors.Select(x => x.Description).ToString());
throw new KavitaException("oidc.errors.creating-user"); throw new KavitaException("errors.oidc.creating-user");
} }
AddDefaultStreamsToUser(user, mapper); AddDefaultStreamsToUser(user, mapper);
@ -151,7 +151,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
if (roles.Count == 0) return; if (roles.Count == 0) return;
var errors = await accountService.UpdateRolesForUser(user, roles); var errors = await accountService.UpdateRolesForUser(user, roles);
if (errors.Any()) throw new KavitaException("oidc.errors.syncing-user"); if (errors.Any()) throw new KavitaException("errors.oidc.syncing-user");
} }
private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user) private async Task SyncLibraries(ClaimsPrincipal claimsPrincipal, AppUser user)

View file

@ -11,6 +11,7 @@ import {NavigationEnd, Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component"; import {SettingsTabId} from "../sidenav/preference-nav/preference-nav.component";
import {WikiLink} from "../_models/wiki"; import {WikiLink} from "../_models/wiki";
import {OidcService} from "./oidc.service";
/** /**
* NavItem used to construct the dropdown or NavLinkModal on mobile * NavItem used to construct the dropdown or NavLinkModal on mobile
@ -34,6 +35,7 @@ interface NavItem {
export class NavService { export class NavService {
private readonly accountService = inject(AccountService); private readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@ -173,12 +175,28 @@ export class NavService {
} }
logout() { logout() {
this.oidcService.logout();
this.accountService.logout(); this.accountService.logout();
this.hideNavBar(); this.hideNavBar();
this.hideSideNav(); this.hideSideNav();
this.router.navigateByUrl('/login'); this.router.navigateByUrl('/login');
} }
handleLogin() {
this.showNavBar();
this.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');
}
}
/** /**
* Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state. * Shows the side nav. When being visible, the side nav will automatically return to previous collapsed state.
*/ */

View file

@ -1,12 +1,10 @@
import {DestroyRef, Injectable} from '@angular/core'; import {DestroyRef, effect, inject, Injectable, signal} from '@angular/core';
import {OAuthService} from "angular-oauth2-oidc"; import {OAuthErrorEvent, OAuthService} from "angular-oauth2-oidc";
import {BehaviorSubject, from} from "rxjs"; import {BehaviorSubject, from} from "rxjs";
import {HttpClient} from "@angular/common/http"; import {HttpClient} from "@angular/common/http";
import {environment} from "../../environments/environment"; import {environment} from "../../environments/environment";
import {OidcConfig} from "../admin/_models/oidc-config"; import {OidcPublicConfig} from "../admin/_models/oidc-config";
import {AccountService} from "./account.service"; import {AccountService} from "./account.service";
import {NavService} from "./nav.service";
import {Router} from "@angular/router";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {take} from "rxjs/operators"; import {take} from "rxjs/operators";
@ -15,24 +13,29 @@ import {take} from "rxjs/operators";
}) })
export class OidcService { export class OidcService {
/* private readonly oauth2 = inject(OAuthService);
TODO: Further cleanup, nicer handling for the user private readonly httpClient = inject(HttpClient);
See: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards private readonly accountService = inject(AccountService);
Service: https://github.com/jeroenheijmans/sample-angular-oauth2-oidc-with-auth-guards/blob/master/src/app/core/auth.service.ts private readonly destroyRef = inject(DestroyRef);
*/
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
settingsSource = new BehaviorSubject<OidcConfig | null>(null);
settings$ = this.settingsSource.asObservable();
constructor( private readonly _ready = signal(false);
private oauth2: OAuthService, public readonly ready = this._ready.asReadonly();
private httpClient: HttpClient, private readonly _settings = signal<OidcPublicConfig | undefined>(undefined);
private accountService: AccountService, public readonly settings = this._settings.asReadonly();
private navService: NavService,
private router: Router, constructor() {
private destroyRef: DestroyRef, // log events in dev
) { if (!environment.production) {
this.oauth2.events.subscribe(event => {
if (event instanceof OAuthErrorEvent) {
console.error('OAuthErrorEvent Object:', event);
} else {
console.debug('OAuthEvent Object:', event);
}
});
}
this.config().subscribe(oidcSetting => { this.config().subscribe(oidcSetting => {
if (!oidcSetting.authority) { if (!oidcSetting.authority) {
@ -42,14 +45,15 @@ export class OidcService {
this.oauth2.configure({ this.oauth2.configure({
issuer: oidcSetting.authority, issuer: oidcSetting.authority,
clientId: oidcSetting.clientId, clientId: oidcSetting.clientId,
requireHttps: oidcSetting.authority.startsWith("https://"), // Require https in production unless localhost
requireHttps: environment.production ? 'remoteOnly' : false,
redirectUri: window.location.origin + "/oidc/callback", redirectUri: window.location.origin + "/oidc/callback",
postLogoutRedirectUri: window.location.origin + "/login", postLogoutRedirectUri: window.location.origin + "/login",
showDebugInformation: true, showDebugInformation: !environment.production,
responseType: 'code', responseType: 'code',
scope: "openid profile email roles offline_access", scope: "openid profile email roles offline_access",
}); });
this.settingsSource.next(oidcSetting); this._settings.set(oidcSetting);
this.oauth2.setupAutomaticSilentRefresh(); this.oauth2.setupAutomaticSilentRefresh();
this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => { this.oauth2.events.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
@ -67,7 +71,7 @@ export class OidcService {
next: success => { next: success => {
if (!success) return; if (!success) return;
this.tryLogin(); this._ready.set(true);
}, },
error: error => { error: error => {
console.log(error); console.log(error);
@ -76,52 +80,21 @@ export class OidcService {
}) })
} }
private tryLogin() {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) return;
if (this.token) { login() {
this.accountService.loginByToken(this.token).subscribe({
next: _ => {
this.doLogin();
}
});
}
});
}
oidcLogin() {
this.oauth2.initLoginFlow(); this.oauth2.initLoginFlow();
} }
config() {
return this.httpClient.get<OidcConfig>(this.baseUrl + "oidc/config");
}
get token() {
return this.oauth2.getAccessToken();
}
logout() { logout() {
this.oauth2.logOut(); this.oauth2.logOut();
} }
private doLogin() { config() {
this.navService.showNavBar(); return this.httpClient.get<OidcPublicConfig>(this.baseUrl + "oidc/config");
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');
}
} }
get token() {
return this.oauth2.getAccessToken();
}
} }

View file

@ -7,3 +7,9 @@ export interface OidcConfig {
provisionUserSettings: boolean; provisionUserSettings: boolean;
autoLogin: boolean; autoLogin: boolean;
} }
export interface OidcPublicConfig {
authority: string;
clientId: string;
autoLogin: boolean;
}

View file

@ -1,7 +1,7 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef, DestroyRef, effect,
HostListener, HostListener,
inject, inject,
OnInit OnInit
@ -100,6 +100,21 @@ export class AppComponent implements OnInit {
this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup this.localizationService.getLocales().subscribe(); // This will cache the localizations on startup
// Login automatically when a token is available
effect(() => {
const ready = this.oidcService.ready();
if (!ready || !this.oidcService.token) return;
this.accountService.loginByToken(this.oidcService.token).subscribe({
next: () => {
this.navService.handleLogin();
},
error: err => {
console.error(err);
}
});
});
} }
@HostListener('window:resize', ['$event']) @HostListener('window:resize', ['$event'])

View file

@ -48,7 +48,6 @@ import {Breakpoint, UtilityService} from "../../../shared/_services/utility.serv
import {WikiLink} from "../../../_models/wiki"; import {WikiLink} from "../../../_models/wiki";
import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component"; import {NavLinkModalComponent} from "../nav-link-modal/nav-link-modal.component";
import {MetadataService} from "../../../_services/metadata.service"; import {MetadataService} from "../../../_services/metadata.service";
import {OidcService} from "../../../_services/oidc.service";
@Component({ @Component({
selector: 'app-nav-header', selector: 'app-nav-header',
@ -66,7 +65,6 @@ export class NavHeaderComponent implements OnInit {
private readonly searchService = inject(SearchService); private readonly searchService = inject(SearchService);
private readonly filterUtilityService = inject(FilterUtilitiesService); private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly accountService = inject(AccountService); protected readonly accountService = inject(AccountService);
private readonly oidcService = inject(OidcService);
private readonly cdRef = inject(ChangeDetectorRef); private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
protected readonly navService = inject(NavService); protected readonly navService = inject(NavService);
@ -136,14 +134,6 @@ export class NavHeaderComponent implements OnInit {
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
logout() {
this.accountService.logout();
this.navService.hideNavBar();
this.navService.hideSideNav();
this.oidcService.logout();
this.router.navigateByUrl('/login');
}
moveFocus() { moveFocus() {
this.document.getElementById('content')?.focus(); this.document.getElementById('content')?.focus();
} }

View file

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

View file

@ -1,11 +1,10 @@
import {ChangeDetectorRef, Component, OnInit} from '@angular/core'; import {ChangeDetectorRef, Component, OnInit, signal} from '@angular/core';
import {SplashContainerComponent} from "../_components/splash-container/splash-container.component"; import {SplashContainerComponent} from "../_components/splash-container/splash-container.component";
import {TranslocoDirective} from "@jsverse/transloco"; import {TranslocoDirective} from "@jsverse/transloco";
import {AccountService} from "../../_services/account.service"; import {AccountService} from "../../_services/account.service";
import {Router} from "@angular/router"; import {Router} from "@angular/router";
import {NavService} from "../../_services/nav.service"; import {NavService} from "../../_services/nav.service";
import {take} from "rxjs/operators"; import {take} from "rxjs/operators";
import {OidcService} from "../../_services/oidc.service";
@Component({ @Component({
selector: 'app-oidc-callback', selector: 'app-oidc-callback',
@ -16,16 +15,15 @@ import {OidcService} from "../../_services/oidc.service";
templateUrl: './oidc-callback.component.html', templateUrl: './oidc-callback.component.html',
styleUrl: './oidc-callback.component.scss' styleUrl: './oidc-callback.component.scss'
}) })
export class OidcCallbackComponent implements OnInit{ export class OidcCallbackComponent implements OnInit {
error: string = ''; showSplash = signal(false);
constructor( constructor(
private accountService: AccountService, private accountService: AccountService,
private router: Router, private router: Router,
private navService: NavService, private navService: NavService,
private readonly cdRef: ChangeDetectorRef, private readonly cdRef: ChangeDetectorRef,
private oidcService: OidcService,
) { ) {
this.navService.hideNavBar(); this.navService.hideNavBar();
this.navService.hideSideNav(); this.navService.hideSideNav();
@ -40,6 +38,9 @@ export class OidcCallbackComponent implements OnInit{
this.cdRef.markForCheck(); this.cdRef.markForCheck();
} }
}); });
// Show back to log in splash only after 1s, for a more seamless experience
setTimeout(() => this.showSplash.set(true), 1000);
} }
goToLogin() { goToLogin() {

View file

@ -2,8 +2,8 @@
<app-splash-container> <app-splash-container>
<ng-container title><h2>{{t('title')}}</h2></ng-container> <ng-container title><h2>{{t('title')}}</h2></ng-container>
<ng-container body> <ng-container body>
<ng-container *ngIf="isLoaded"> <ng-container *ngIf="isLoaded()">
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow"> <form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow()">
<div class="card-text"> <div class="card-text">
<div class="mb-3"> <div class="mb-3">
<label for="username" class="form-label visually-hidden">{{t('username')}}</label> <label for="username" class="form-label visually-hidden">{{t('username')}}</label>
@ -22,13 +22,13 @@
</div> </div>
<div class="sign-in"> <div class="sign-in">
<button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting">{{t('submit')}}</button> <button class="btn btn-outline-primary" type="submit" [disabled]="isSubmitting()">{{t('submit')}}</button>
</div> </div>
</div> </div>
</form> </form>
@if (oidcEnabled) { @if (oidcService.ready()) {
<button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.oidcLogin()">{{t('oidc')}}</button> <button [ngbTooltip]="t('oidc-tooltip')" class="btn btn-outline-primary mt-2" (click)="oidcService.login()">{{t('oidc')}}</button>
} }
</ng-container> </ng-container>

View file

@ -1,4 +1,12 @@
import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit} from '@angular/core'; import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component, computed,
DestroyRef, effect, inject,
OnInit,
signal
} from '@angular/core';
import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
import {ActivatedRoute, Router, RouterLink} from '@angular/router'; import {ActivatedRoute, Router, RouterLink} from '@angular/router';
import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; import {NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap';
@ -13,6 +21,7 @@ import {TRANSLOCO_SCOPE, TranslocoDirective} from "@jsverse/transloco";
import {environment} from "../../../environments/environment"; import {environment} from "../../../environments/environment";
import {OidcService} from "../../_services/oidc.service"; import {OidcService} from "../../_services/oidc.service";
import {forkJoin} from "rxjs"; import {forkJoin} from "rxjs";
import {takeUntilDestroyed, toSignal} from "@angular/core/rxjs-interop";
@Component({ @Component({
@ -24,6 +33,15 @@ import {forkJoin} from "rxjs";
}) })
export class UserLoginComponent implements OnInit { 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 oidcService = inject(OidcService);
baseUrl = environment.apiUrl; baseUrl = environment.apiUrl;
loginForm: FormGroup = new FormGroup({ loginForm: FormGroup = new FormGroup({
@ -34,27 +52,31 @@ export class UserLoginComponent implements OnInit {
/** /**
* If there are no admins on the server, this will enable the registration to kick in. * If there are no admins on the server, this will enable the registration to kick in.
*/ */
firstTimeFlow: boolean = true; firstTimeFlow = signal(true);
/** /**
* Used for first time the page loads to ensure no flashing * Used for first time the page loads to ensure no flashing
*/ */
isLoaded: boolean = false; isLoaded = signal(false);
isSubmitting = false; isSubmitting = signal(false);
oidcEnabled = false; /**
* undefined until query params are read
*/
skipAutoLogin = signal<boolean | undefined>(undefined)
constructor( constructor() {
private accountService: AccountService, this.navService.hideNavBar();
private router: Router, this.navService.hideSideNav();
private memberService: MemberService,
private toastr: ToastrService, effect(() => {
private navService: NavService, const skipAutoLogin = this.skipAutoLogin();
private readonly cdRef: ChangeDetectorRef, const oidcConfig = this.oidcService.settings();
private route: ActivatedRoute, if (!oidcConfig || skipAutoLogin === undefined) return;
protected oidcService: OidcService,
) { if (oidcConfig.autoLogin && !skipAutoLogin) {
this.navService.hideNavBar(); this.oidcService.login()
this.navService.hideSideNav(); }
} });
}
ngOnInit(): void { ngOnInit(): void {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => { this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
@ -68,34 +90,24 @@ export class UserLoginComponent implements OnInit {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => { this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists; this.firstTimeFlow.set(!adminExists);
if (this.firstTimeFlow) { if (this.firstTimeFlow()) {
this.router.navigateByUrl('registration/register'); this.router.navigateByUrl('registration/register');
return; return;
} }
this.isLoaded = true; this.isLoaded.set(true);
this.cdRef.markForCheck();
}); });
this.route.queryParamMap.subscribe(params => { this.route.queryParamMap.subscribe(params => {
const val = params.get('apiKey'); const val = params.get('apiKey');
if (val != null && val.length > 0) { if (val != null && val.length > 0) {
this.login(val); this.login(val);
return;
} }
const skipAutoLogin = params.get('skipAutoLogin') === 'true'; this.skipAutoLogin.set(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()
}
});
}); });
} }
@ -104,33 +116,18 @@ export class UserLoginComponent implements OnInit {
login(apiKey: string = '') { login(apiKey: string = '') {
const model = this.loginForm.getRawValue(); const model = this.loginForm.getRawValue();
model.apiKey = apiKey; model.apiKey = apiKey;
this.isSubmitting = true; this.isSubmitting.set(true);
this.cdRef.markForCheck(); this.accountService.login(model).subscribe({
this.accountService.login(model).subscribe(() => { next: () => {
this.loginForm.reset(); this.loginForm.reset();
this.doLogin() this.navService.handleLogin()
this.isSubmitting = false; this.isSubmitting.set(false);
this.cdRef.markForCheck(); },
}, err => { error: (err) => {
this.toastr.error(err.error); this.toastr.error(err.error);
this.isSubmitting = false; this.isSubmitting.set(false);
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

@ -13,13 +13,6 @@
"oidc": { "oidc": {
"title": "OpenID Connect Callback", "title": "OpenID Connect Callback",
"login": "Back to login screen", "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": { "settings": {
"save": "{{common.save}}", "save": "{{common.save}}",
"notice": "Notice", "notice": "Notice",
@ -2455,7 +2448,14 @@
"invalid-password-reset-url": "Invalid reset password url", "invalid-password-reset-url": "Invalid reset password url",
"delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete", "delete-theme-in-use": "Theme is currently in use by at least one user, cannot delete",
"theme-manual-upload": "There was an issue creating Theme from manual upload", "theme-manual-upload": "There was an issue creating Theme from manual upload",
"theme-already-in-use": "Theme already exists by that name" "theme-already-in-use": "Theme already exists by that name",
"oidc": {
"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"
}
}, },
"metadata-builder": { "metadata-builder": {