Cleanup, nicer flow
This commit is contained in:
parent
465723fedf
commit
0b64ea1622
15 changed files with 184 additions and 179 deletions
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
12
API/DTOs/Settings/OidcPublicConfigDto.cs
Normal file
12
API/DTOs/Settings/OidcPublicConfigDto.cs
Normal 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; }
|
||||||
|
}
|
|
@ -123,7 +123,7 @@ public static class IdentityServiceExtensions
|
||||||
options.Events = new JwtBearerEvents
|
options.Events = new JwtBearerEvents
|
||||||
{
|
{
|
||||||
OnMessageReceived = SetTokenFromQuery,
|
OnMessageReceived = SetTokenFromQuery,
|
||||||
OnTokenValidated = OidcClaimsPrincipalConverter
|
OnTokenValidated = OidcClaimsPrincipalConverter,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,3 +7,9 @@ export interface OidcConfig {
|
||||||
provisionUserSettings: boolean;
|
provisionUserSettings: boolean;
|
||||||
autoLogin: boolean;
|
autoLogin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OidcPublicConfig {
|
||||||
|
authority: string;
|
||||||
|
clientId: string;
|
||||||
|
autoLogin: boolean;
|
||||||
|
}
|
||||||
|
|
|
@ -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'])
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue