feat(authentication): Added idle-functionality to logout inactive users after a certain time of inactivity. (TV-535)
Squashed commit of the following:
commit ca11dd079dca884634ead2696899cfedbfc826f1
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date: Wed Nov 10 14:45:48 2021 +0100
Made changes after PR
commit a8a51ecdabf0d32aa67b814eee530c9a01d405a9
Merge: b1677aff 3b6ce438
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date: Wed Nov 10 12:47:54 2021 +0100
Merge branch 'develop' into feature/TV-535-idle-functionality
commit b1677aff5210288f4a86ba235dd1acb5d415f71f
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date: Wed Nov 10 09:17:55 2021 +0100
Added better check to avoid blank screens
commit 0129f3f6a1d4884d3f669b109bc9b8667fc6281c
Author: Erik Tiekstra <erik.tiekstra@arbetsformedlingen.se>
Date: Wed Nov 10 09:12:55 2021 +0100
Added idle functionality
This commit is contained in:
@@ -1,3 +1,19 @@
|
||||
<router-outlet></router-outlet>
|
||||
|
||||
<msfa-toast-list></msfa-toast-list>
|
||||
|
||||
<digi-ng-dialog
|
||||
[afActive]="userIsIdle$ | async"
|
||||
(afOnPrimaryClick)="setUserAsActive()"
|
||||
(afOnSecondaryClick)="logout()"
|
||||
(afOnInactive)="setUserAsActive()"
|
||||
afHeading="Du verkar inaktiv"
|
||||
afHeadingLevel="h2"
|
||||
afPrimaryButtonText="Fortsätt sessionen"
|
||||
afSecondaryButtonText="Logga ut"
|
||||
>
|
||||
<p>Din session är på väg att löpa ut på grund av inaktivitet. Vill du fortsätta eller logga ut?</p>
|
||||
<p *ngIf="timeLeftBeforeLogout$ | async as timeLeftBeforeLogout">
|
||||
Du blir automatiskt utloggad om <time>{{timeLeftBeforeLogout}}</time>
|
||||
</p>
|
||||
</digi-ng-dialog>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
|
||||
import { environment } from '@msfa-environment';
|
||||
import { AuthenticationService } from '@msfa-services/api/authentication.service';
|
||||
import { IdleService } from '@msfa-services/api/idle.service';
|
||||
import { Observable } from 'rxjs';
|
||||
import { filter, map, switchMap } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
@@ -12,11 +15,16 @@ import { filter, map, switchMap } from 'rxjs/operators';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent {
|
||||
userIsIdle$: Observable<boolean> = this.idleService.isIdle$;
|
||||
timeLeftBeforeLogout$: Observable<string> = this.idleService.timeLeftBeforeLogout$;
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
private router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private titleService: Title
|
||||
private titleService: Title,
|
||||
private idleService: IdleService,
|
||||
private authenticationService: AuthenticationService
|
||||
) {
|
||||
this.document.body.dataset.version = environment.version;
|
||||
|
||||
@@ -31,6 +39,14 @@ export class AppComponent {
|
||||
this.titleService.setTitle(`${pageTitle ? `${pageTitle} - ` : ''}Mina sidor för fristående aktörer`);
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.authenticationService.logout();
|
||||
}
|
||||
|
||||
setUserAsActive(): void {
|
||||
this.idleService.setActive();
|
||||
}
|
||||
}
|
||||
|
||||
function traverseUntilNoChildRoute(route: ActivatedRoute): ActivatedRoute {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { DigiNgDialogModule } from '@af/digi-ng/_dialog/dialog';
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
|
||||
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||
import localeSe from '@angular/common/locales/sv';
|
||||
import { ErrorHandler, LOCALE_ID, NgModule, Provider } from '@angular/core';
|
||||
import { BrowserModule } from '@angular/platform-browser';
|
||||
import { ApmErrorHandler } from '@elastic/apm-rum-angular';
|
||||
import { environment } from '@msfa-environment';
|
||||
import { AuthInterceptor } from '@msfa-interceptors/auth.interceptor';
|
||||
import { CustomErrorHandler } from '@msfa-interceptors/custom-error-handler';
|
||||
import { AppRoutingModule } from './app-routing.module';
|
||||
import { AppComponent } from './app.component';
|
||||
import { ToastListModule } from './components/toast-list/toast-list.module';
|
||||
import { LoggingModule } from './logging.module';
|
||||
import { AvropModule } from './pages/avrop/avrop.module';
|
||||
import { CustomErrorHandler } from '@msfa-interceptors/custom-error-handler';
|
||||
|
||||
registerLocaleData(localeSe);
|
||||
const providers: Provider[] = [
|
||||
@@ -30,7 +31,15 @@ if (environment.production) {
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [LoggingModule, BrowserModule, HttpClientModule, AppRoutingModule, ToastListModule, AvropModule],
|
||||
imports: [
|
||||
LoggingModule,
|
||||
BrowserModule,
|
||||
HttpClientModule,
|
||||
AppRoutingModule,
|
||||
ToastListModule,
|
||||
AvropModule,
|
||||
DigiNgDialogModule,
|
||||
],
|
||||
providers,
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export const AUTH_TOKEN_KEY = 'id_token';
|
||||
export const AUTH_TOKEN_EXPIRE_KEY = 'expires_at';
|
||||
export const AUTH_TOKEN_EXPIRES_IN_KEY = 'expires_in';
|
||||
export const SELECTED_ORGANIZATION_NUMBER_KEY = 'selected_orgnr';
|
||||
|
||||
export const ALL_LOCAL_STORAGE_KEYS = {
|
||||
export const ALL_LOCAL_STORAGE_KEYS = [
|
||||
AUTH_TOKEN_KEY,
|
||||
AUTH_TOKEN_EXPIRE_KEY,
|
||||
AUTH_TOKEN_EXPIRES_IN_KEY,
|
||||
SELECTED_ORGANIZATION_NUMBER_KEY,
|
||||
};
|
||||
];
|
||||
|
||||
@@ -15,20 +15,35 @@ export class AuthGuard implements CanActivate {
|
||||
return this.authenticationService.isLoggedIn$.pipe(
|
||||
switchMap(loggedIn => {
|
||||
if (loggedIn) {
|
||||
return this.authenticationService.isTokenValid$.pipe(
|
||||
switchMap(({ isValid, isRefreshable }) => {
|
||||
if (isValid) {
|
||||
return of(true);
|
||||
}
|
||||
if (isRefreshable) {
|
||||
return this.authenticationService.refreshToken$();
|
||||
}
|
||||
this.redirectToLoginPage();
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
} else if (route.queryParams.code) {
|
||||
return this.authenticationService.login$(route.queryParams.code).pipe(map(result => !!result));
|
||||
}
|
||||
|
||||
void this.authenticationService.removeLocalStorageData();
|
||||
this.redirectToLoginPage();
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
redirectToLoginPage(): void {
|
||||
this.authenticationService.removeLocalStorageData();
|
||||
|
||||
if (environment.ciam.clientId) {
|
||||
document.location.href = `${environment.ciam.loginUrl}&client_id=${environment.ciam.clientId}&redirect_uri=${window.location.origin}`;
|
||||
} else {
|
||||
void this.router.navigateByUrl(environment.ciam.loginUrl);
|
||||
}
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export interface Ciam {
|
||||
clientId: string;
|
||||
loginUrl: string;
|
||||
logoutUrl: string;
|
||||
refreshUrl: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface Environment {
|
||||
clientId?: string;
|
||||
loginUrl: string;
|
||||
logoutUrl: string;
|
||||
refreshUrl: string;
|
||||
};
|
||||
activeFeatures: Feature[];
|
||||
elastic?: {
|
||||
|
||||
@@ -1,28 +1,47 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { ALL_LOCAL_STORAGE_KEYS, AUTH_TOKEN_EXPIRE_KEY, AUTH_TOKEN_KEY } from '@msfa-constants/local-storage-keys';
|
||||
import {
|
||||
ALL_LOCAL_STORAGE_KEYS,
|
||||
AUTH_TOKEN_EXPIRES_IN_KEY,
|
||||
AUTH_TOKEN_EXPIRE_KEY,
|
||||
AUTH_TOKEN_KEY,
|
||||
} from '@msfa-constants/local-storage-keys';
|
||||
import { environment } from '@msfa-environment';
|
||||
import { AuthenticationResponse } from '@msfa-models/api/authentication.response.model';
|
||||
import { Authentication, mapAuthApiResponseToAuthenticationResult } from '@msfa-models/authentication.model';
|
||||
import { add, isBefore } from 'date-fns';
|
||||
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
|
||||
import { map, tap } from 'rxjs/operators';
|
||||
import { add, isBefore, sub } from 'date-fns';
|
||||
import { BehaviorSubject, combineLatest, Observable, of } from 'rxjs';
|
||||
import { catchError, distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthenticationService {
|
||||
private readonly _refreshBaseUrl = environment.ciam.refreshUrl;
|
||||
private _token$ = new BehaviorSubject<string>(null);
|
||||
private _expiration$ = new BehaviorSubject<number>(null);
|
||||
private _expiresAt$ = new BehaviorSubject<number>(null);
|
||||
private _expiresIn$ = new BehaviorSubject<number>(null);
|
||||
|
||||
isLoggedIn$: Observable<boolean> = combineLatest([this._token$, this._expiration$]).pipe(
|
||||
map(([token, expiration]) => {
|
||||
if (token && expiration) {
|
||||
return isBefore(new Date(), expiration);
|
||||
}
|
||||
isLoggedIn$: Observable<boolean> = this._token$.pipe(
|
||||
distinctUntilChanged(),
|
||||
map(token => !!token)
|
||||
);
|
||||
|
||||
return false;
|
||||
// We set token validity at 1 minute prior expiration time.
|
||||
// Refresh will be possible until expiration time.
|
||||
// TODO: Refesh solution
|
||||
isTokenValid$: Observable<{ isValid: boolean; isRefreshable: boolean }> = combineLatest([
|
||||
this._token$,
|
||||
this._expiresAt$,
|
||||
]).pipe(
|
||||
filter(([token, expiresAt]) => !!(token && expiresAt)),
|
||||
map(([, expiresAt]) => {
|
||||
return {
|
||||
isValid: isBefore(new Date(), sub(expiresAt, { minutes: 1 })),
|
||||
isRefreshable: false,
|
||||
// isRefreshable: isBefore(new Date(), expiresAt),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -30,14 +49,45 @@ export class AuthenticationService {
|
||||
return `${environment.api.url}/auth/token?accessCode=${code}`;
|
||||
}
|
||||
|
||||
private _setSession(authenticationResult: Authentication): void {
|
||||
public get expiresAtDate(): number {
|
||||
return this._expiresAt$.getValue();
|
||||
}
|
||||
|
||||
refreshToken$(): Observable<boolean> {
|
||||
return this.httpClient.get(`${this._refreshBaseUrl}`, { responseType: 'blob' }).pipe(
|
||||
tap(() => {
|
||||
this._setSession({
|
||||
idToken: this._token$.getValue(),
|
||||
expiresIn: 3600,
|
||||
});
|
||||
}),
|
||||
map(() => true),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
// TODO: Information dialog that the user is logged out?
|
||||
console.error('Could not refresh token... User needs to login again: ', error);
|
||||
this._setSession(null);
|
||||
return of(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _setSession(authenticationResult: Authentication | null): void {
|
||||
if (!authenticationResult) {
|
||||
this.removeLocalStorageData();
|
||||
this._token$.next(null);
|
||||
this._expiresAt$.next(null);
|
||||
this._expiresIn$.next(null);
|
||||
} else {
|
||||
const expiresAt = add(new Date(), { seconds: authenticationResult.expiresIn });
|
||||
|
||||
this._expiresIn$.next(authenticationResult.expiresIn);
|
||||
localStorage.setItem(AUTH_TOKEN_EXPIRES_IN_KEY, authenticationResult.expiresIn.toString());
|
||||
this._token$.next(authenticationResult.idToken);
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, authenticationResult.idToken);
|
||||
this._expiration$.next(expiresAt.valueOf());
|
||||
this._expiresAt$.next(expiresAt.valueOf());
|
||||
localStorage.setItem(AUTH_TOKEN_EXPIRE_KEY, JSON.stringify(expiresAt.valueOf()));
|
||||
}
|
||||
}
|
||||
|
||||
login$(authorizationCodeFromCiam: string): Observable<Authentication> {
|
||||
this.removeLocalStorageData();
|
||||
@@ -52,21 +102,23 @@ export class AuthenticationService {
|
||||
);
|
||||
}
|
||||
|
||||
private _getLocalStorageData(): { id_token: string; expires_at: number } {
|
||||
private get _localStorageData(): { id_token: string; expires_at: number; expires_in: number } {
|
||||
const id_token = localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
const expiration = localStorage.getItem(AUTH_TOKEN_EXPIRE_KEY);
|
||||
const expiresAt = localStorage.getItem(AUTH_TOKEN_EXPIRE_KEY);
|
||||
const expiresIn = localStorage.getItem(AUTH_TOKEN_EXPIRES_IN_KEY);
|
||||
|
||||
return id_token && expiration
|
||||
return id_token && expiresAt && expiresIn
|
||||
? {
|
||||
id_token,
|
||||
expires_at: +JSON.parse(expiration),
|
||||
expires_at: +JSON.parse(expiresAt),
|
||||
expires_in: +expiresIn,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
public removeLocalStorageData(): void {
|
||||
Object.values(ALL_LOCAL_STORAGE_KEYS).forEach(value => {
|
||||
localStorage.removeItem(value);
|
||||
ALL_LOCAL_STORAGE_KEYS.forEach(key => {
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,11 +127,12 @@ export class AuthenticationService {
|
||||
}
|
||||
|
||||
constructor(private httpClient: HttpClient, private router: Router) {
|
||||
const localStorageData = this._getLocalStorageData();
|
||||
const localStorageData = this._localStorageData;
|
||||
|
||||
if (localStorageData) {
|
||||
this._token$.next(localStorageData.id_token);
|
||||
this._expiration$.next(localStorageData.expires_at);
|
||||
this._expiresAt$.next(localStorageData.expires_at);
|
||||
this._expiresIn$.next(localStorageData.expires_in);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { UnsubscribeDirective } from '@msfa-directives/unsubscribe.directive';
|
||||
import { BehaviorSubject, fromEvent, merge, Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AuthenticationService } from './authentication.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class IdleService extends UnsubscribeDirective {
|
||||
private _eventsArray$: Observable<Event>[] = [
|
||||
fromEvent(document, 'click'),
|
||||
fromEvent(document, 'wheel'),
|
||||
fromEvent(document, 'mousemove'),
|
||||
fromEvent(document, 'touchstart'),
|
||||
fromEvent(document, 'keyup'),
|
||||
fromEvent(window, 'resize'),
|
||||
fromEvent(window, 'scroll'),
|
||||
];
|
||||
private _allEvents$ = merge(...this._eventsArray$);
|
||||
private _isIdle$ = new BehaviorSubject<boolean>(false);
|
||||
public isIdle$: Observable<boolean> = this._isIdle$.asObservable();
|
||||
private _isActive$ = new BehaviorSubject<boolean>(true);
|
||||
public isActive$: Observable<boolean> = this._isActive$.asObservable();
|
||||
private _timeLeftBeforeLogoutInSeconds$ = new BehaviorSubject<number>(2 * 60);
|
||||
|
||||
private _idleAfterMS = 10 * 60 * 1000; // 10 minutes
|
||||
private _autoLogoutAfterMS = 2 * 60 * 1000; // 2 minutes
|
||||
private _idleTimeout;
|
||||
private _autoLogoutTimeout;
|
||||
private _autoLogoutInterval;
|
||||
|
||||
constructor(private authenticationService: AuthenticationService) {
|
||||
super();
|
||||
super.unsubscribeOnDestroy(
|
||||
this._allEvents$.subscribe(() => {
|
||||
this._setNewTimeoutIfActive();
|
||||
})
|
||||
);
|
||||
this._setNewTimeoutIfActive();
|
||||
}
|
||||
|
||||
private _setNewTimeoutIfActive(): void {
|
||||
if (this._isActive$.getValue()) {
|
||||
this._clearTimeouts();
|
||||
this._setIdleTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
private _setIdleTimeout(): void {
|
||||
this._idleTimeout = setTimeout(() => {
|
||||
this._isActive$.next(false);
|
||||
this._isIdle$.next(true);
|
||||
this._setAutoLogoutTimeout();
|
||||
}, this._idleAfterMS);
|
||||
}
|
||||
|
||||
private _setAutoLogoutTimeout(): void {
|
||||
this._autoLogoutTimeout = setTimeout(() => {
|
||||
this.authenticationService.logout();
|
||||
}, this._autoLogoutAfterMS);
|
||||
|
||||
this._autoLogoutInterval = setInterval(() => {
|
||||
const currentTimeLeft = this._timeLeftBeforeLogoutInSeconds$.getValue();
|
||||
this._timeLeftBeforeLogoutInSeconds$.next(currentTimeLeft - 1);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private _clearTimeouts(): void {
|
||||
clearTimeout(this._autoLogoutTimeout);
|
||||
clearTimeout(this._idleTimeout);
|
||||
clearInterval(this._autoLogoutInterval);
|
||||
this._timeLeftBeforeLogoutInSeconds$.next(2 * 60);
|
||||
}
|
||||
|
||||
public setActive(): void {
|
||||
this._isIdle$.next(false);
|
||||
this._isActive$.next(true);
|
||||
this._clearTimeouts();
|
||||
this._setIdleTimeout();
|
||||
}
|
||||
|
||||
public get timeLeftBeforeLogout$(): Observable<string> {
|
||||
return this._timeLeftBeforeLogoutInSeconds$.pipe(
|
||||
map(timeLeftInSeconds => {
|
||||
const minutes = Math.floor(timeLeftInSeconds / 60);
|
||||
const seconds = timeLeftInSeconds - minutes * 60;
|
||||
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,19 @@ export const CIAM_TEST: Ciam = {
|
||||
clientId: '5d08c2e4-763e-42f6-b858-24e4773bb83d',
|
||||
loginUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/oauth2/authorization?response_type=code&scope=openid',
|
||||
logoutUrl: 'https://ciam-test.arbetsformedlingen.se:8443/uas/logout',
|
||||
refreshUrl: '/refreshToken',
|
||||
};
|
||||
|
||||
export const CIAM_PROD: Ciam = {
|
||||
clientId: '71010833-e445-4bbc-926a-775247b7a6e3',
|
||||
loginUrl: 'https://ciam.arbetsformedlingen.se/uas/oauth2/authorization?response_type=code&scope=openid',
|
||||
logoutUrl: 'https://ciam.arbetsformedlingen.se/uas/logout',
|
||||
refreshUrl: '/refreshToken',
|
||||
};
|
||||
|
||||
export const CIAM_MOCK: Ciam = {
|
||||
clientId: '',
|
||||
loginUrl: '/mock-login',
|
||||
logoutUrl: '/mock-login',
|
||||
refreshUrl: '/refreshToken',
|
||||
};
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"pathRewrite": {
|
||||
"^/logging": ""
|
||||
}
|
||||
},
|
||||
"/refreshToken": {
|
||||
"target": "https://ciam-test.arbetsformedlingen.se:8443/uas/refresh",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "debug",
|
||||
"pathRewrite": { "^/refreshToken": "" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,15 +72,16 @@ http {
|
||||
proxy_pass $ELASTIC_SERVER_URL;
|
||||
}
|
||||
|
||||
# Ciam Refresh token
|
||||
location /refreshToken {
|
||||
#proxy_http_version 1.1;
|
||||
proxy_pass $CIAM_REFRESH_URL;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /40x.html {
|
||||
|
||||
}
|
||||
location = /40x.html {}
|
||||
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
|
||||
}
|
||||
location = /50x.html {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
export CIAM_REFRESH_URL
|
||||
export ELASTIC_SERVER_URL
|
||||
|
||||
envsubst '${ELASTIC_SERVER_URL} ' < /usr/share/container-scripts/nginx/nginx-start/nginx.template > /etc/nginx/nginx.conf
|
||||
envsubst '${ELASTIC_SERVER_URL} ${CIAM_REFRESH_URL}' < /usr/share/container-scripts/nginx/nginx-start/nginx.template > /etc/nginx/nginx.conf
|
||||
|
||||
@@ -23,7 +23,7 @@ pipeline {
|
||||
echo '### Generating build tag... ###'
|
||||
script {
|
||||
def packageJson = readJSON file: 'package.json'
|
||||
BUILD_TAG = "dev_v${packageJson.version}_${env.BUILD_NUMBER}_${CURRENT_COMMIT}"
|
||||
BUILD_TAG = "playground_v${packageJson.version}_${env.BUILD_NUMBER}_${CURRENT_COMMIT}"
|
||||
echo '### Build tag ###'
|
||||
echo "This is the build tag: ${BUILD_TAG}"
|
||||
}
|
||||
Reference in New Issue
Block a user