diff --git a/src/app/interceptors/auth-token.interceptor.ts b/src/app/interceptors/auth-token.interceptor.ts
index 731d6e1..7a64dfe 100644
--- a/src/app/interceptors/auth-token.interceptor.ts
+++ b/src/app/interceptors/auth-token.interceptor.ts
@@ -8,6 +8,7 @@ let lastServiceErrorNotificationAt = 0;
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
const token = getToken();
const requestPath = req.url.split('?')[0];
+ const isChangePasswordEndpoint = requestPath.endsWith('/auth/change-password');
const isAuthEndpoint =
requestPath.endsWith('/auth/login') ||
requestPath.endsWith('/auth/register');
@@ -40,6 +41,7 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
if (
hasToken &&
!isAuthEndpoint &&
+ !isChangePasswordEndpoint &&
error instanceof HttpErrorResponse &&
error.status === 401
) {
diff --git a/src/app/layouts/app-layout/app-layout.css b/src/app/layouts/app-layout/app-layout.css
index 17eb963..b3fe525 100644
--- a/src/app/layouts/app-layout/app-layout.css
+++ b/src/app/layouts/app-layout/app-layout.css
@@ -80,3 +80,22 @@
.app-user-trigger:hover {
background: rgba(255, 255, 255, 0.92);
}
+
+.app-password-success-banner {
+ position: fixed;
+ top: 76px;
+ left: 50%;
+ z-index: 10010;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ transform: translateX(-50%);
+ border: 1px solid #86efac;
+ border-radius: 999px;
+ background: #f0fdf4;
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.14);
+ color: #166534;
+ font-size: 13px;
+ font-weight: 700;
+ padding: 10px 14px;
+}
diff --git a/src/app/layouts/app-layout/app-layout.html b/src/app/layouts/app-layout/app-layout.html
index b3b49f1..dca7c80 100644
--- a/src/app/layouts/app-layout/app-layout.html
+++ b/src/app/layouts/app-layout/app-layout.html
@@ -27,11 +27,18 @@
- @if (changePasswordOpen && loggedUser()?.username; as username) {
+ @if (changePasswordSuccess()) {
+
+
+ {{ changePasswordSuccess() }}
+
+ }
+
+ @if (changePasswordOpen() && loggedUser()?.username; as username) {
diff --git a/src/app/layouts/app-layout/app-layout.ts b/src/app/layouts/app-layout/app-layout.ts
index 4948a2a..c15dab2 100644
--- a/src/app/layouts/app-layout/app-layout.ts
+++ b/src/app/layouts/app-layout/app-layout.ts
@@ -1,4 +1,4 @@
-import { afterNextRender, Component, inject } from '@angular/core';
+import { afterNextRender, Component, inject, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
@@ -26,9 +26,10 @@ export class AppLayout {
private containersService = inject(ContainersService);
loggedUser = this.authService.loggedInUser;
- changePasswordOpen = false;
- changePasswordSaving = false;
- changePasswordError: string | null = null;
+ changePasswordOpen = signal(false);
+ changePasswordSaving = signal(false);
+ changePasswordError = signal(null);
+ changePasswordSuccess = signal(null);
constructor() {
afterNextRender(() => {
@@ -48,29 +49,33 @@ logout() {
openChangePasswordDialog() {
if (!this.loggedUser()?.username?.trim()) return;
- this.changePasswordError = null;
- this.changePasswordOpen = true;
+ this.changePasswordError.set(null);
+ this.changePasswordOpen.set(true);
}
closeChangePasswordDialog() {
- if (this.changePasswordSaving) return;
- this.changePasswordOpen = false;
- this.changePasswordError = null;
+ if (this.changePasswordSaving()) return;
+ this.changePasswordOpen.set(false);
+ this.changePasswordError.set(null);
}
submitPasswordChange(request: ChangePasswordRequest) {
- this.changePasswordSaving = true;
- this.changePasswordError = null;
+ this.changePasswordSaving.set(true);
+ this.changePasswordError.set(null);
this.authService.changePassword(request).subscribe({
next: () => {
- this.changePasswordSaving = false;
- this.changePasswordOpen = false;
- window.alert('Password changed successfully.');
+ this.changePasswordSaving.set(false);
+ this.changePasswordOpen.set(false);
+ this.changePasswordError.set(null);
+ this.changePasswordSuccess.set('Password changed successfully.');
+ setTimeout(() => {
+ this.changePasswordSuccess.set(null);
+ }, 3000);
},
error: (error) => {
- this.changePasswordSaving = false;
- this.changePasswordError = error instanceof Error ? error.message : 'Unable to change password.';
+ this.changePasswordSaving.set(false);
+ this.changePasswordError.set(error instanceof Error ? error.message : 'Unable to change password.');
}
});
}
diff --git a/src/app/services/authorization/authorization-call.ts b/src/app/services/authorization/authorization-call.ts
index 19bc26a..f540eeb 100644
--- a/src/app/services/authorization/authorization-call.ts
+++ b/src/app/services/authorization/authorization-call.ts
@@ -42,7 +42,45 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
}
override changePassword(request: ChangePasswordRequest): Observable {
- return this.http.post(`${environment.apiUrl}/auth/change-password`, request);
+ return this.http
+ .post(`${environment.apiUrl}/auth/change-password`, request, { responseType: 'text' })
+ .pipe(
+ map(() => undefined),
+ catchError((error: unknown) => {
+ if (error instanceof HttpErrorResponse) {
+ const message = this.extractHttpErrorMessage(error)
+ ?? (error.status === 400 ? 'Missing required fields.' : null)
+ ?? (error.status === 401 ? 'Current password is invalid' : null)
+ ?? (error.status === 404 ? 'User not found' : null)
+ ?? 'Unable to change password.';
+ return throwError(() => new Error(message));
+ }
+ return throwError(() => error);
+ })
+ );
+ }
+
+ private extractHttpErrorMessage(error: HttpErrorResponse): string | null {
+ const payload = error.error;
+ if (typeof payload === 'string' && payload.trim().length > 0) {
+ return payload.trim();
+ }
+ if (payload && typeof payload === 'object') {
+ const record = payload as Record;
+ const directMessage = record['message'];
+ if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
+ return directMessage.trim();
+ }
+ const errorMessage = record['error'];
+ if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) {
+ return errorMessage.trim();
+ }
+ const details = record['details'];
+ if (typeof details === 'string' && details.trim().length > 0) {
+ return details.trim();
+ }
+ }
+ return null;
}
private extractToken(...sources: Array | undefined>): unknown {
diff --git a/src/app/services/dialogs/node-settings-dialog.ts b/src/app/services/dialogs/node-settings-dialog.ts
index 21fcdcb..b6ec9d9 100644
--- a/src/app/services/dialogs/node-settings-dialog.ts
+++ b/src/app/services/dialogs/node-settings-dialog.ts
@@ -32,6 +32,7 @@ export type NodeSettingsDialogInput = {
title?: string;
fields: NodeSettingField[];
initial?: NodeSettingsValues;
+ previewOnly?: boolean;
onValuesChange?: (draft: NodeSettingsValues) =>
Promise | NodeSettingsDialogRefresh | null;
};
@@ -42,6 +43,7 @@ export class NodeSettingsDialogService {
title: string;
fields: NodeSettingField[];
initial: NodeSettingsValues;
+ previewOnly: boolean;
onValuesChange: ((draft: NodeSettingsValues) =>
Promise | NodeSettingsDialogRefresh | null) | null;
resolve: (value: NodeSettingsValues | null) => void;
@@ -55,6 +57,7 @@ export class NodeSettingsDialogService {
title: input.title ?? "Node Settings",
fields: input.fields,
initial: input.initial ?? {},
+ previewOnly: input.previewOnly === true,
onValuesChange: input.onValuesChange ?? null,
resolve
});
diff --git a/src/app/shared/change-password-dialog/change-password-dialog.html b/src/app/shared/change-password-dialog/change-password-dialog.html
index f9bac3e..d6a1d1c 100644
--- a/src/app/shared/change-password-dialog/change-password-dialog.html
+++ b/src/app/shared/change-password-dialog/change-password-dialog.html
@@ -32,9 +32,6 @@
- @if (isInvalid(passwordForm.newPassword())) {
- {{ passwordForm.newPassword().errors()[0].message }}
- }
diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.html b/src/app/shared/node-settings-dialog/node-settings-dialog.html
index 4a2521b..26653b2 100644
--- a/src/app/shared/node-settings-dialog/node-settings-dialog.html
+++ b/src/app/shared/node-settings-dialog/node-settings-dialog.html
@@ -106,8 +106,12 @@
+ @if (isPreviewOnly()) {
+
+ } @else {
+ }
diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.ts b/src/app/shared/node-settings-dialog/node-settings-dialog.ts
index 05fc9ea..f5d4dfe 100644
--- a/src/app/shared/node-settings-dialog/node-settings-dialog.ts
+++ b/src/app/shared/node-settings-dialog/node-settings-dialog.ts
@@ -56,6 +56,10 @@ export class NodeSettingsDialogHostComponent {
this.dialog.close({ ...this.draft });
}
+ isPreviewOnly(): boolean {
+ return this.state()?.previewOnly === true;
+ }
+
setFieldValue(key: string, value: string | boolean) {
this.draft[key] = value;
void this.refreshFieldsFromDraft();
diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css
index dfe9a1b..a2a9a05 100644
--- a/src/app/shared/nodes/container-node/container-node.css
+++ b/src/app/shared/nodes/container-node/container-node.css
@@ -157,6 +157,15 @@
color: #f8fafc;
}
+.container-node__clone {
+ border: 0;
+ width: 30px;
+ height: 30px;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.22);
+ color: #eff6ff;
+}
+
.container-node__header-actions {
margin-left: auto;
display: inline-flex;
diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html
index 7aec37f..e2b544a 100644
--- a/src/app/shared/nodes/container-node/container-node.html
+++ b/src/app/shared/nodes/container-node/container-node.html
@@ -32,7 +32,10 @@
}
@if (!isReadonly) {
-