Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c675e4a
feat: optional build with base_href for all projects
jdroenner Mar 4, 2026
1465f53
handle routing in oicd redirectUrl
jdroenner Mar 4, 2026
afce777
logs
jdroenner Mar 4, 2026
91183e1
re-use redirectUri for oidc login
jdroenner Mar 4, 2026
dbf3ac9
moooore debug prints
jdroenner Mar 5, 2026
18b392e
mooooooore debug logs
jdroenner Mar 5, 2026
fb54f0f
mooooore debug log
jdroenner Mar 5, 2026
d31a589
fix typo -_-
jdroenner Mar 5, 2026
9a1805b
relative navigation and derived redirect
jdroenner Mar 6, 2026
505a5d8
separate restoreRoute and redirectUrl
jdroenner Mar 11, 2026
d08e1bd
use origin for URL
jdroenner Mar 11, 2026
67ed56d
debug session
jdroenner Mar 11, 2026
16803c2
change pathPrefix
jdroenner Mar 12, 2026
e27ea23
remove logs and / stripping
jdroenner Mar 12, 2026
bfc3616
separate initialize and trigger steps
jdroenner Mar 13, 2026
18b8a96
move async calls on observable into mergeMap
jdroenner Mar 18, 2026
9e9b56b
remove # from esg indicator service and fallback to /
jdroenner Mar 19, 2026
a1b4937
Merge branch 'main' of github.com:geo-engine/geoengine-ui into option…
jdroenner Mar 19, 2026
f71e0df
remove deprecated typing
jdroenner Mar 19, 2026
a9d4d6d
explain the basehref_ image name part
jdroenner Mar 21, 2026
dcb835e
more const and strings from methods
jdroenner Mar 23, 2026
94e1d3a
promise things in gfbio handleQueryParams
jdroenner Mar 24, 2026
0814c77
lints
jdroenner Mar 24, 2026
7e05d59
lint
jdroenner Mar 24, 2026
c4df666
lost an await
jdroenner Mar 24, 2026
8456b00
debug baseHref logic
jdroenner Mar 24, 2026
21c8f0c
try dynamic base href
jdroenner Mar 25, 2026
e4faec8
more complex domain + base_href derive
jdroenner Mar 27, 2026
c6dc3b6
more base_href
jdroenner Mar 27, 2026
5bc37a4
remove .log
jdroenner Mar 27, 2026
a10c068
remove edge case handling and thro an error just in case
jdroenner Mar 27, 2026
d82d5f4
remove base_href from build
jdroenner Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .github/workflows/containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
- {project: ebv-analyzer}
- {project: ecometrics}
- {project: esg-indicator-service}
- {project: manager, angular_base_href: "/manager/"}
- {project: manager}

runs-on: ubuntu-24.04

Expand All @@ -45,7 +45,6 @@ jobs:
FULL_TAG_NAME: nightly
CONTAINER_REPOSITORY_BRANCH: main
BUILD_TAG: gis
ANGULAR_BASE_HREF:

steps:
- name: Modify TAG_NAME if on `tag_name` is set on `workflow_dispatch`
Expand All @@ -65,10 +64,7 @@ jobs:
- name: Modify `BUILD_TAG` if dashboard
if: env.BUILD_TAG != 'gis' && env.BUILD_TAG != 'manager'
run: echo "BUILD_TAG=dashboards/${{env.BUILD_TAG}}" >> $GITHUB_ENV

- name: Set additional params
if: matrix.app.angular_base_href
run: echo "ANGULAR_BASE_HREF=${{matrix.app.angular_base_href}}" >> $GITHUB_ENV


- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -92,7 +88,6 @@ jobs:
podman build \
--tag "geoengine-ui:${{env.FULL_TAG_NAME}}" \
--build-arg GEOENGINE_UI_PROJECT="${{env.BUILD_TAG}}" \
--build-arg GEOENGINE_UI_ANGULAR_BASE_HREF="${{env.ANGULAR_BASE_HREF}}" \
-f container/geoengine-ui/Dockerfile \
.

Expand Down
12 changes: 0 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
"@types/jasminewd2": "~2.0.13",
"@types/node": "^25.3.2",
"@types/topojson-specification": "^1.0.5",
"@types/uuid": "^11.0.0",
"@vitest/browser-playwright": "4.0.18",
"angular-eslint": "^21.2.0",
"eslint": "^9.39.3",
Expand Down
29 changes: 18 additions & 11 deletions projects/common/src/lib/login/login.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,28 +88,35 @@ export class LoginComponent implements OnInit {

readonly loginRedirect = (): string => {
// Priority: explicit returnUrl query param -> route data.loginRedirect -> default input
const qp = this.route.snapshot.queryParamMap.get('returnUrl');
if (qp) return qp;
const queryRedirect = this.route.snapshot.queryParamMap.get('returnUrl');
if (queryRedirect) {
return queryRedirect;
}

const dataRedirect = this.route.snapshot.data['loginRedirect'];
if (dataRedirect) return dataRedirect as string;
if (dataRedirect) {
return dataRedirect as string;
}

const inputDefaultRedirect = this.defaultRedirect();
if (inputDefaultRedirect) {
return inputDefaultRedirect;
}

return this.defaultRedirect();
// if there is no redirect set, redirect to the application root.
return '/';
};

ngOnInit(): void {
void this.onInit();
}

async onInit(): Promise<void> {
const usesHashNavigation = window.location.hash.startsWith('#/');
const hashPrefix = usesHashNavigation ? '#' : '';

const redirectUri = new URL(hashPrefix + this.loginRedirect(), window.location.href).toString();

// check if OIDC login is enabled
try {
const idr = await this.userService.oidcInit(redirectUri);
// resolve the route to restore after login
const oidcRestoreRoute = this.loginRedirect();
const idr = await this.userService.oidcInit(oidcRestoreRoute);
this.oidcUrl = idr.url;
this.formStatus.set(FormStatus.Oidc);

Expand All @@ -136,7 +143,7 @@ export class LoginComponent implements OnInit {
}

oidcLogin(): void {
this.formStatus.set(FormStatus.Loading);
this.formStatus.set(FormStatus.Oidc);
window.location.href = this.oidcUrl;
}

Expand Down
146 changes: 95 additions & 51 deletions projects/common/src/lib/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
mergeMap,
of,
} from 'rxjs';
import {Location} from '@angular/common';
import {UUID} from '../datasets/dataset.model';
import {isDefined} from '../util/conversions';
import {ActivatedRoute, Router} from '@angular/router';
Expand All @@ -36,19 +37,22 @@ import {utc} from 'moment';
import {CommonConfig} from '../config.service';
import {NotificationService} from '../notification.service';

const PATH_PREFIX = window.location.pathname.replace(/\//g, '_').replace(/-/g, '_');

/**
* A service that is responsible for retrieving user information and modifying the current user.
*/
@Injectable({
providedIn: 'root',
})
export class UserService {
static readonly OIDC_RESTORE_ROUTE_KEY = 'oidcRestoreRoute';
static readonly IS_HASH_SUFFIX = '#/';
static readonly SESSION_KEY_SUFFIX = 'session';

protected readonly config = inject(CommonConfig);
protected readonly notificationService = inject(NotificationService);
protected readonly router = inject(Router);
protected readonly activatedRoute = inject(ActivatedRoute);
protected readonly location = inject(Location);

protected readonly session$ = new ReplaySubject<Session | undefined>(1);
protected readonly backendStatus$ = new BehaviorSubject<BackendStatus>({available: false, initial: true});
Expand All @@ -70,34 +74,29 @@ export class UserService {
this.session$.subscribe((session) => {
// storage of the session
this.saveSessionInBrowser(session);
if (!session) return;
this.userApi.next(new UserApi(apiConfigurationWithAccessKey(session.sessionToken)));
this.sessionApi.next(new SessionApi(apiConfigurationWithAccessKey(session.sessionToken)));
});

this.getBackendStatus().subscribe((status) => {
void this.tryLogin(status, oidcParams);
});

// update backend info when backend is available
// eslint-disable-next-line @typescript-eslint/no-misused-promises
this.getBackendStatus().subscribe(async (status) => {
if (status.available) {
const info = await this.backendApi.serverInfoHandler();
this.backendInfo$.next(info);
}
});

this.getSessionStream().subscribe({
next: (session) => {
this.userApi.next(new UserApi(apiConfigurationWithAccessKey(session.sessionToken)));
this.sessionApi.next(new SessionApi(apiConfigurationWithAccessKey(session.sessionToken)));
},
});
this.getBackendStatus()
.pipe(mergeMap((status, _index) => this.setBackendInfo(status).then(() => this.tryLogin(status, oidcParams))))
.subscribe();
Comment thread
ChristianBeilschmidt marked this conversation as resolved.

// update quota when session changes or update is triggered
this.createSessionQuotaStream();

// now, trigger an update of the backend status once. While this is an async call, we don't need to await here.
void this.triggerBackendStatusUpdate();
}

async setBackendInfo(status: BackendStatus): Promise<void> {
if (status.available) {
const info = await this.backendApi.serverInfoHandler();
this.backendInfo$.next(info);
}
}

async tryLogin(
status: BackendStatus,
oidcParams:
Expand Down Expand Up @@ -127,16 +126,11 @@ export class UserService {

this.sessionInitialized = true;

if (oidcParams && sessionStorage.getItem('redirectUri')) {
this.oidcLogin(oidcParams)
.pipe(first())
.subscribe(() => {
void this.router.navigate([], {
// eslint-disable-next-line @typescript-eslint/naming-convention
queryParams: {session_state: undefined, state: undefined, code: undefined},
queryParamsHandling: 'merge',
});
});
const oidcRestoreRoute = sessionStorage.getItem(UserService.OIDC_RESTORE_ROUTE_KEY);
if (oidcParams && oidcRestoreRoute) {
const sess = await this.oidcLogin(oidcParams);
this.session$.next(sess);
this.router.navigateByUrl(oidcRestoreRoute);
} else {
try {
// restore old session if possible
Expand Down Expand Up @@ -237,6 +231,61 @@ export class UserService {
return this.backendInfo$;
}

/**
* This calls Angulars prepareExternalUrl for route "/".
* @returns the url string
**/
getSpaExternalUrl(): string {
return this.location.prepareExternalUrl('/');
}

/**
* This returns the url incl. base path of the single page application.
* We use it for routung and restoring after OIDC login.
* In most cases it is 'https://abc.app.geoengine.io/'.
* If angulars '--base-href=something' is used, it is 'https://abc.app.geoengine.io/something/'.
*
* @returns the url string
*/
getSpaExternalUrlWithDomain(): string {
return window.location.origin + this.getSpaBaseHref();
}

/**
* This returns only the APP_BASE_HREF of the application.
* Since the APP_BASE_HREF is not directly accessable, we get it from the external url.
* @returns the APP_BASE_HREF
*/
getSpaBaseHref(): string {
const baseHrefUrl = this.getSpaExternalUrl();
if (!baseHrefUrl.startsWith('/')) {
// Angular allows to set APP_BASE_HREF to start with the origin. If that happens, this must be covered here!
throw new Error("base_href must start with '/'");
}
if (baseHrefUrl.endsWith('#')) {
return baseHrefUrl.substring(0, baseHrefUrl.length - 2);
}
return baseHrefUrl;
}

/**
* For local storage we use the APP_BASE_HREF as part of the key. (To be able to use multiple apps with one domain?)
* To get a uniform key, we replace '/' and '-' with '_'.
* @returns APP_BASE_HREF with '/' and '-' replaces by '_'.
*/
getBaseHrefBasedKey(): string {
//
return this.getSpaBaseHref().replace(/\//g, '_').replace(/-/g, '_');
}

/**
* This allows to identify if hash or path routing is used.
* @returns 'true' if hash based routing is used.
*/
isHashRouting(): boolean {
return this.getSpaExternalUrl().endsWith(UserService.IS_HASH_SUFFIX);
}

isGuestUserStream(): Observable<boolean> {
return this.getSessionStream().pipe(map((s) => !s.user?.email || !s.user.realName));
}
Expand Down Expand Up @@ -342,40 +391,35 @@ export class UserService {
this.logoutCallback = callback;
}

oidcInit(redirectUri: string): Promise<AuthCodeRequestURL> {
sessionStorage.setItem('redirectUri', redirectUri);
oidcInit(oidcRestoreRoute: string): Promise<AuthCodeRequestURL> {
sessionStorage.setItem(UserService.OIDC_RESTORE_ROUTE_KEY, oidcRestoreRoute);

return new SessionApi().oidcInit({
redirectUri: redirectUri,
redirectUri: this.getSpaExternalUrlWithDomain(),
});
}

oidcLogin(request: {sessionState: string; code: string; state: string}): Observable<Session> {
const result = new ReplaySubject<Session>();

new SessionApi()
oidcLogin(request: {sessionState: string; code: string; state: string}): Promise<Session> {
const sess = new SessionApi()
.oidcLogin({
authCodeResponse: request,
redirectUri: sessionStorage.getItem('redirectUri')!,
redirectUri: this.getSpaExternalUrlWithDomain(),
})
.then((response) => {
const session = this.sessionFromDict(response);
this.session$.next(session);
result.next(session);
result.complete();
sessionStorage.removeItem('redirectUri');
})
.catch((error) => result.error(error));
sessionStorage.removeItem(UserService.OIDC_RESTORE_ROUTE_KEY);
return session;
});

return result.asObservable();
return sess;
}

saveSettingInLocalStorage(keyValue: string, setting: string): void {
localStorage.setItem(PATH_PREFIX + keyValue, setting);
localStorage.setItem(this.getBaseHrefBasedKey() + keyValue, setting);
}

getSettingFromLocalStorage(keyValue: string): string | null {
return localStorage.getItem(PATH_PREFIX + keyValue);
return localStorage.getItem(this.getBaseHrefBasedKey() + keyValue);
}

/**
Expand All @@ -396,14 +440,14 @@ export class UserService {

protected saveSessionInBrowser(session: Session | undefined): void {
if (session) {
localStorage.setItem(PATH_PREFIX + 'session', session.sessionToken);
localStorage.setItem(this.getBaseHrefBasedKey() + UserService.SESSION_KEY_SUFFIX, session.sessionToken);
} else {
localStorage.removeItem(PATH_PREFIX + 'session');
localStorage.removeItem(this.getBaseHrefBasedKey() + UserService.SESSION_KEY_SUFFIX);
}
}

protected restoreSessionFromBrowser(): Promise<Session> {
const sessionToken = localStorage.getItem(PATH_PREFIX + 'session') ?? '';
const sessionToken = localStorage.getItem(this.getBaseHrefBasedKey() + UserService.SESSION_KEY_SUFFIX) ?? '';

return this.createSessionWithToken(sessionToken);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {ApplicationConfig, inject, provideAppInitializer} from '@angular/core';
import {provideRouter, withHashLocation} from '@angular/router';
import {provideRouter} from '@angular/router';
import {provideAnimations} from '@angular/platform-browser/animations';

import {routes} from './app.routes';
Expand All @@ -10,7 +10,7 @@ import {provideHttpClient} from '@angular/common/http';

export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withHashLocation()),
provideRouter(routes),
provideAnimations(),
provideHttpClient(),
provideAppInitializer(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ export class LoginComponent implements OnInit {
}

async onInit(): Promise<void> {
const redirectUri = window.location.href.replace(/\/signin$/, '/dashboard');
const restoreRoute = 'dashboard';

// check if OIDC login is enabled
try {
const idr = await this.userService.oidcInit(redirectUri);
const idr = await this.userService.oidcInit(restoreRoute);
this.oidcUrl = idr.url;
this.formStatus.set(FormStatus.Oidc);
} catch {
Expand Down
Loading
Loading