@c8y/login 1024.8.3 → 1024.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@c8y/login",
3
- "version": "1024.8.3",
3
+ "version": "1024.10.1",
4
4
  "description": "This package is used to scaffold a login application for Cumulocity IoT.",
5
5
  "dependencies": {
6
6
  "@angular/animations": "^21.2.0",
7
7
  "@angular/cdk": "^21.2.0",
8
- "@c8y/bootstrap": "1024.8.3",
9
- "@c8y/client": "1024.8.3",
10
- "@c8y/ngx-components": "1024.8.3",
11
- "@c8y/style": "1024.8.3",
8
+ "@c8y/bootstrap": "1024.10.1",
9
+ "@c8y/client": "1024.10.1",
10
+ "@c8y/ngx-components": "1024.10.1",
11
+ "@c8y/style": "1024.10.1",
12
12
  "monaco-editor": "~0.53.0",
13
13
  "ngx-bootstrap": "21.0.1",
14
14
  "rxjs": "7.8.2",
15
15
  "zone.js": "~0.15.1"
16
16
  },
17
17
  "devDependencies": {
18
- "@c8y/devkit": "1024.8.3",
19
- "@c8y/options": "1024.8.3"
18
+ "@c8y/devkit": "1024.10.1",
19
+ "@c8y/options": "1024.10.1"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "@angular/common": ">=21 <22"
@@ -0,0 +1,105 @@
1
+ <div class="download-slot">
2
+ <h3 class="m-b-8">
3
+ @switch (state()) {
4
+ @case ('ready') {
5
+ {{ 'Your file is ready' | translate }}
6
+ }
7
+ @case ('error') {
8
+ <i
9
+ class="text-danger m-r-8"
10
+ c8yIcon="exclamation-circle"
11
+ ></i
12
+ >{{ 'The file could not be loaded' | translate }}
13
+ }
14
+ @default {
15
+ {{ 'File download' | translate }}
16
+ }
17
+ }
18
+ </h3>
19
+ @switch (state()) {
20
+ @case ('loading') {
21
+ <c8y-loading [attr.aria-label]="'Loading' | translate"></c8y-loading>
22
+ }
23
+ @case ('error') {
24
+ {{ errorMessage() }}
25
+ }
26
+ @case ('ready') {
27
+ @let mo = managedObject();
28
+ <p class="m-b-24">
29
+ {{ 'Click "Download" to save it.' | translate }}
30
+ </p>
31
+ <div class="d-flex a-i-start gap-16 m-b-24">
32
+ <i
33
+ class="icon-32 text-muted"
34
+ [c8yIcon]="{ name: mo?.name } | fileIcon"
35
+ aria-hidden="true"
36
+ ></i>
37
+ <div class="flex-grow min-width-0">
38
+ <p class="text-truncate m-b-0">
39
+ {{ mo?.name }}
40
+ </p>
41
+ <p>
42
+ <small class="text-muted">
43
+ <em>{{ 'Size' | translate }}:</em> {{ mo?.length | bytes }} |
44
+ <em>{{ 'Type' | translate }}:</em> {{ mo?.contentType }}
45
+ </small>
46
+ </p>
47
+ <p>
48
+ <small class="text-muted">
49
+ <em>{{ 'Last modified' | translate }}:</em> {{ mo?.lastUpdated | c8yDate }}
50
+ </small>
51
+ </p>
52
+ </div>
53
+ </div>
54
+ <button
55
+ class="btn btn-primary flex-no-shrink"
56
+ [title]="'Download' | translate"
57
+ type="button"
58
+ [attr.data-cy]="'c8y-login--download-button'"
59
+ [disabled]="isDownloading()"
60
+ (click)="download()"
61
+ >
62
+ @if (isDownloading()) {
63
+ <i
64
+ class="icon-16 animated rotateOut"
65
+ [c8yIcon]="'circle-o-notch'"
66
+ aria-hidden="true"
67
+ ></i>
68
+ } @else {
69
+ <i
70
+ [c8yIcon]="'download'"
71
+ aria-hidden="true"
72
+ ></i>
73
+ }
74
+ {{ 'Download' | translate }}
75
+ </button>
76
+ }
77
+ }
78
+ </div>
79
+ @if (state() !== 'loading') {
80
+ @if (state() === 'ready') {
81
+ <p class="text-muted m-t-24 m-b-8">
82
+ {{
83
+ 'You can also open one of your applications below — now, or after downloading the file.'
84
+ | translate
85
+ }}
86
+ </p>
87
+ }
88
+ @if (state() === 'error') {
89
+ <p class="text-muted m-t-24 m-b-8">
90
+ {{ 'You can also open one of your applications below.' | translate }}
91
+ </p>
92
+ }
93
+ <c8y-app-switcher-inline></c8y-app-switcher-inline>
94
+ <button
95
+ class="btn btn-default btn-lg btn-block m-t-16"
96
+ [title]="'Logout' | translate"
97
+ (click)="logout()"
98
+ >
99
+ <i
100
+ [c8yIcon]="'logout'"
101
+ aria-hidden="true"
102
+ ></i>
103
+ {{ 'Logout' | translate }}
104
+ </button>
105
+ }
@@ -0,0 +1,96 @@
1
+ import { ChangeDetectionStrategy, Component, inject, input, OnInit, signal } from '@angular/core';
2
+ import { DOCUMENT } from '@angular/common';
3
+ import { IManagedObject, InventoryBinaryService, InventoryService } from '@c8y/client';
4
+ import {
5
+ AlertService,
6
+ AppSwitcherInlineComponent,
7
+ BytesPipe,
8
+ C8yTranslatePipe,
9
+ DatePipe,
10
+ GenericFileIconPipe,
11
+ IconDirective,
12
+ LoadingComponent
13
+ } from '@c8y/ngx-components';
14
+ import { gettext } from '@c8y/ngx-components/gettext';
15
+ import { TranslateService } from '@ngx-translate/core';
16
+ import { LoginService } from '../login.service';
17
+
18
+ type DownloadState = 'loading' | 'ready' | 'error';
19
+
20
+ @Component({
21
+ selector: 'c8y-binary-download',
22
+ templateUrl: './binary-download.component.html',
23
+ changeDetection: ChangeDetectionStrategy.OnPush,
24
+ imports: [
25
+ AppSwitcherInlineComponent,
26
+ BytesPipe,
27
+ C8yTranslatePipe,
28
+ DatePipe,
29
+ GenericFileIconPipe,
30
+ IconDirective,
31
+ LoadingComponent
32
+ ]
33
+ })
34
+ export class BinaryDownloadComponent implements OnInit {
35
+ readonly binaryId = input.required<string>();
36
+
37
+ protected readonly state = signal<DownloadState>('loading');
38
+ protected readonly isDownloading = signal(false);
39
+ protected readonly managedObject = signal<IManagedObject | null>(null);
40
+ protected readonly errorMessage = signal<string | null>(null);
41
+
42
+ private readonly LOAD_ERROR = gettext(
43
+ 'The link might be mistyped or outdated, or the file might have been deleted. Check the link and try again, or contact the person who shared it with you.'
44
+ );
45
+
46
+ private readonly inventory = inject(InventoryService);
47
+ private readonly inventoryBinary = inject(InventoryBinaryService);
48
+ private readonly alert = inject(AlertService);
49
+ private readonly translate = inject(TranslateService);
50
+ private readonly loginService = inject(LoginService);
51
+ private readonly document = inject(DOCUMENT);
52
+
53
+ async ngOnInit() {
54
+ try {
55
+ const { data } = await this.inventory.detail(this.binaryId(), { withChildren: false });
56
+ if (data['c8y_IsBinary'] == null) {
57
+ this.errorMessage.set(this.translate.instant(this.LOAD_ERROR));
58
+ this.state.set('error');
59
+ return;
60
+ }
61
+ this.managedObject.set(data);
62
+ this.state.set('ready');
63
+ } catch (ex) {
64
+ console.error('Failed to load binary for download:', ex);
65
+ this.errorMessage.set(this.translate.instant(this.LOAD_ERROR));
66
+ this.state.set('error');
67
+ }
68
+ }
69
+
70
+ protected async download() {
71
+ this.alert.clearAll();
72
+ this.isDownloading.set(true);
73
+ try {
74
+ const mo = this.managedObject();
75
+ const res = await this.inventoryBinary.download(mo.id);
76
+ const blob = await res.blob();
77
+ const url = URL.createObjectURL(blob);
78
+ const link = this.document.createElement('a');
79
+ link.href = url;
80
+ link.download = mo['name'] ?? String(mo.id);
81
+ this.document.body.appendChild(link);
82
+ link.click();
83
+ this.document.body.removeChild(link);
84
+ URL.revokeObjectURL(url);
85
+ this.alert.add({ text: gettext('File downloaded.'), type: 'success', timeout: 10_000 });
86
+ } catch (ex) {
87
+ this.alert.addServerFailure(ex);
88
+ } finally {
89
+ this.isDownloading.set(false);
90
+ }
91
+ }
92
+
93
+ protected logout() {
94
+ this.loginService.logout();
95
+ }
96
+ }
@@ -1,141 +1,141 @@
1
- <div
2
- id="oauth"
3
- class="m-b-40"
4
- *ngIf="oauthOptions.initRequest && oauthOptions.visibleOnLoginPage"
5
- >
6
- <button
7
- class="btn btn-default btn-block btn-lg form-group m-t-8"
8
- title="{{ oauthOptions.buttonName | translate }}"
9
- type="button"
10
- (click)="redirectToOauth()"
11
- >
12
- <i
13
- class="pull-left"
14
- [c8yIcon]="'sign-in'"
15
- ></i>
16
- {{ oauthOptions.buttonName | translate }}
17
- </button>
18
- </div>
19
-
20
- <form
21
- class="loginForm"
22
- (ngSubmit)="login()"
23
- #loginForm="ngForm"
24
- *ngIf="showLoginForm"
25
- novalidate
26
- >
27
- <span
28
- class="legend form-block center"
29
- *ngIf="!(oauthOptions.initRequest && oauthOptions.visibleOnLoginPage); else orLegend"
30
- translate
1
+ @if (oauthOptions.initRequest && oauthOptions.visibleOnLoginPage) {
2
+ <div
3
+ class="m-b-40"
4
+ id="oauth"
31
5
  >
32
- Login
33
- </span>
34
-
35
- <ng-template #orLegend>
36
- <div
37
- class="legend form-block center"
38
- translate
6
+ <button
7
+ class="btn btn-default btn-block btn-lg form-group m-t-8"
8
+ title="{{ oauthOptions.buttonName | translate }}"
9
+ type="button"
10
+ (click)="redirectToOauth()"
39
11
  >
40
- or enter your credentials`prefixed by login button`
41
- </div>
42
- </ng-template>
12
+ <i
13
+ class="pull-left"
14
+ [c8yIcon]="'sign-in'"
15
+ ></i>
16
+ {{ oauthOptions.buttonName | translate }}
17
+ </button>
18
+ </div>
19
+ }
43
20
 
44
- <c8y-form-group
45
- class="tenantField m-b-40"
46
- id="tenantField"
47
- *ngIf="showTenant"
48
- >
49
- <label
50
- for="tenant"
51
- translate
52
- >
53
- Tenant ID
54
- </label>
55
- <input
56
- class="form-control"
57
- id="tenant"
58
- placeholder="{{ 'e.g.' | translate }} t12345"
59
- name="tenant"
60
- type="text"
61
- required
62
- [(ngModel)]="model.tenant"
63
- #tenant="ngModel"
64
- autocapitalize="off"
65
- autocorrect="off"
66
- placeholder-no-required-hint
67
- [readonly]="loginViewParams.disableTenant"
68
- />
69
- </c8y-form-group>
70
- <c8y-form-group class="m-b-40">
71
- <label
72
- for="user"
73
- translate
74
- >
75
- Username
76
- </label>
77
- <input
78
- class="form-control"
79
- id="user"
80
- placeholder="{{ 'e.g. joe or joe.doe@example.com`LOCALIZE`' | translate }}"
81
- name="user"
82
- type="text"
83
- required
84
- [(ngModel)]="model.user"
85
- #user="ngModel"
86
- autocapitalize="off"
87
- autocorrect="off"
88
- placeholder-no-required-hint
89
- />
90
- </c8y-form-group>
91
- <c8y-form-group class="m-b-40">
92
- <label
93
- for="password"
94
- translate
95
- >
96
- Password
97
- </label>
98
- <c8y-password-input
99
- name="password"
100
- required
101
- [id]="'password'"
102
- [(ngModel)]="model.password"
103
- [autocomplete]="'off'"
104
- ></c8y-password-input>
105
- </c8y-form-group>
106
- <div
107
- class="form-group "
108
- *ngIf="showBasicAuth"
21
+ @if (showLoginForm) {
22
+ <form
23
+ class="loginForm"
24
+ (ngSubmit)="login()"
25
+ #loginForm="ngForm"
26
+ novalidate
109
27
  >
110
- <label
111
- class="c8y-checkbox"
112
- title="{{ 'Remember me' | translate }}"
113
- >
28
+ @if (!(oauthOptions.initRequest && oauthOptions.visibleOnLoginPage)) {
29
+ <span
30
+ class="legend form-block center"
31
+ translate
32
+ >
33
+ Login
34
+ </span>
35
+ } @else {
36
+ <div
37
+ class="legend form-block center"
38
+ translate
39
+ >
40
+ or enter your credentials`prefixed by login button`
41
+ </div>
42
+ }
43
+ @if (showTenant) {
44
+ <c8y-form-group
45
+ class="tenantField m-b-40"
46
+ id="tenantField"
47
+ >
48
+ <label
49
+ for="tenant"
50
+ translate
51
+ >
52
+ Tenant ID
53
+ </label>
54
+ <input
55
+ class="form-control"
56
+ id="tenant"
57
+ placeholder="{{ 'e.g.' | translate }} t12345"
58
+ name="tenant"
59
+ type="text"
60
+ required
61
+ [(ngModel)]="model.tenant"
62
+ #tenant="ngModel"
63
+ autocapitalize="off"
64
+ autocorrect="off"
65
+ placeholder-no-required-hint
66
+ [readonly]="loginViewParams.disableTenant"
67
+ />
68
+ </c8y-form-group>
69
+ }
70
+ <c8y-form-group class="m-b-40">
71
+ <label
72
+ for="user"
73
+ translate
74
+ >
75
+ Username
76
+ </label>
114
77
  <input
115
- name="remember"
116
- type="checkbox"
117
- [(ngModel)]="loginService.rememberMe"
78
+ class="form-control"
79
+ id="user"
80
+ placeholder="{{ 'e.g. joe or joe.doe@example.com`LOCALIZE`' | translate }}"
81
+ name="user"
82
+ type="text"
83
+ required
84
+ [(ngModel)]="model.user"
85
+ #user="ngModel"
86
+ autocapitalize="off"
87
+ autocorrect="off"
88
+ placeholder-no-required-hint
118
89
  />
119
- <span></span>
120
- <span>{{ 'Remember me' | translate }}</span>
121
- </label>
122
- </div>
123
- <button
124
- class="btn btn-primary btn-lg btn-block form-group"
125
- title="{{ 'Log in' | translate }}"
126
- type="submit"
127
- [disabled]="!loginForm.form.valid || isLoading"
128
- >
129
- {{ 'Log in' | translate }}
130
- </button>
131
- <div class="text-center m-t-8">
90
+ </c8y-form-group>
91
+ <c8y-form-group class="m-b-40">
92
+ <label
93
+ for="password"
94
+ translate
95
+ >
96
+ Password
97
+ </label>
98
+ <c8y-password-input
99
+ name="password"
100
+ required
101
+ [id]="'password'"
102
+ [(ngModel)]="model.password"
103
+ [autocomplete]="'off'"
104
+ ></c8y-password-input>
105
+ </c8y-form-group>
106
+ @if (showBasicAuth) {
107
+ <div class="form-group">
108
+ <label
109
+ class="c8y-checkbox"
110
+ title="{{ 'Remember me' | translate }}"
111
+ >
112
+ <input
113
+ name="remember"
114
+ type="checkbox"
115
+ [(ngModel)]="loginService.rememberMe"
116
+ />
117
+ <span></span>
118
+ <span>{{ 'Remember me' | translate }}</span>
119
+ </label>
120
+ </div>
121
+ }
132
122
  <button
133
- class="btn btn-link btn-sm"
134
- title="{{ 'Forgot password?' | translate }}"
135
- type="button"
136
- (click)="onChangeView.emit({ view: LOGIN_VIEWS.RecoverPassword })"
123
+ class="btn btn-primary btn-lg btn-block form-group"
124
+ title="{{ 'Log in' | translate }}"
125
+ type="submit"
126
+ [disabled]="!loginForm.form.valid || isLoading"
137
127
  >
138
- {{ 'Forgot password?' | translate }}
128
+ {{ 'Log in' | translate }}
139
129
  </button>
140
- </div>
141
- </form>
130
+ <div class="text-center m-t-8">
131
+ <button
132
+ class="btn btn-link btn-sm"
133
+ title="{{ 'Forgot password?' | translate }}"
134
+ type="button"
135
+ (click)="onChangeView.emit({ view: LOGIN_VIEWS.RecoverPassword })"
136
+ >
137
+ {{ 'Forgot password?' | translate }}
138
+ </button>
139
+ </div>
140
+ </form>
141
+ }
@@ -14,7 +14,7 @@ import { gettext } from '@c8y/ngx-components/gettext';
14
14
  import { LoginEvent, LoginViews } from '../login.model';
15
15
  import { CredentialsFromQueryParamsService } from '../credentials-from-query-params.service';
16
16
  import { CredentialsComponentParams } from '../credentials-component-params';
17
- import { NgIf } from '@angular/common';
17
+
18
18
  import { FormsModule } from '@angular/forms';
19
19
 
20
20
  @Component({
@@ -23,7 +23,6 @@ import { FormsModule } from '@angular/forms';
23
23
  styles: [],
24
24
  standalone: true,
25
25
  imports: [
26
- NgIf,
27
26
  IconDirective,
28
27
  FormsModule,
29
28
  C8yTranslateDirective,
@@ -85,7 +84,7 @@ export class CredentialsComponent implements OnInit {
85
84
  const basicAuth = this.loginService.useBasicAuth(this.model);
86
85
  const hasPermission = await this.loginService.login(basicAuth, this.model);
87
86
  if (!hasPermission) {
88
- this.onChangeView.emit({ view: LoginViews.MissingApplicationAccess });
87
+ this.onChangeView.emit({ view: this.loginService.getViewForSuppressedRedirect() });
89
88
  }
90
89
  } catch (e) {
91
90
  if (e.res && e.res.headers && e.res.headers.get(this.PASSWORD_RESET_HEADER_NAME)) {
@@ -16,18 +16,30 @@
16
16
  @switch (currentView) {
17
17
  @case (LOGIN_VIEWS.Credentials) {
18
18
  <span class="{{ platformAnimationSrc ? '' : 'text-center' }}">
19
- <h2
20
- class="m-b-8"
21
- translate
22
- >
23
- Welcome
24
- </h2>
25
- <p
26
- class="text-16 m-b-40"
27
- translate
28
- >
29
- Log in to access your IoT platform.
30
- </p>
19
+ @if (downloadBinaryId) {
20
+ <div class="download-slot">
21
+ <h3 class="m-b-8 d-flex a-i-center">
22
+ <i class="dlt-c8y-icon-info-circle text-info m-r-8"></i>
23
+ {{ 'File download' | translate }}
24
+ </h3>
25
+ <p class="text-16">
26
+ {{ 'A file has been shared with you, log in to download it.' | translate }}
27
+ </p>
28
+ </div>
29
+ } @else {
30
+ <h2
31
+ class="m-b-8"
32
+ translate
33
+ >
34
+ Welcome
35
+ </h2>
36
+ <p
37
+ class="text-16 m-b-40"
38
+ translate
39
+ >
40
+ Log in to access your IoT platform.
41
+ </p>
42
+ }
31
43
  </span>
32
44
  <c8y-credentials
33
45
  (onChangeView)="handleLoginTemplate($event)"
@@ -92,6 +104,9 @@
92
104
  @case (LOGIN_VIEWS.TenantIdSetup) {
93
105
  <c8y-tenant-id-setup (onChangeView)="handleLoginTemplate($event)"></c8y-tenant-id-setup>
94
106
  }
107
+ @case (LOGIN_VIEWS.Download) {
108
+ <c8y-binary-download [binaryId]="downloadBinaryId!"></c8y-binary-download>
109
+ }
95
110
  @case (LOGIN_VIEWS.MissingApplicationAccess) {
96
111
  <span class="{{ platformAnimationSrc ? '' : 'text-center' }}">
97
112
  <h3
@@ -101,6 +101,7 @@ c8y-bootstrap, c8y-login, c8y-cookie-banner {
101
101
  padding: 32px;
102
102
  width: 100%;
103
103
  margin: auto;
104
+ min-width: 0;
104
105
  @media (min-width: 768px) {
105
106
  animation-delay: .5s;
106
107
  max-width: 550px;
@@ -153,3 +154,20 @@ c8y-bootstrap, c8y-login, c8y-cookie-banner {
153
154
  background: none;
154
155
  padding-bottom: 0;
155
156
  }
157
+
158
+ .gap-16{
159
+ gap: 16px;
160
+ }
161
+
162
+
163
+ .download-slot{
164
+ margin-bottom: 24px;
165
+ padding: 24px;
166
+ border-radius: 8px;
167
+ box-shadow: var(--c8y-elevation-sm);
168
+ }
169
+
170
+ .text-truncate{
171
+ text-overflow: ellipsis;
172
+ overflow: hidden;
173
+ }
@@ -29,6 +29,7 @@ import { TenantIdSetupComponent } from './tenant-id-setup/tenant-id-setup.compon
29
29
  import { ProvidePhoneNumberComponent } from './provide-phone-number/provide-phone-number.component';
30
30
  import { SmsChallengeComponent } from './sms-challenge/sms-challenge.component';
31
31
  import { MissingApplicationAccessComponent } from './missing-application-access/missing-application-access.component';
32
+ import { BinaryDownloadComponent } from './binary-download/binary-download.component';
32
33
 
33
34
  @Component({
34
35
  selector: 'c8y-login',
@@ -47,6 +48,7 @@ import { MissingApplicationAccessComponent } from './missing-application-access/
47
48
  AlertOutletComponent,
48
49
  AsyncPipe,
49
50
  MissingApplicationAccessComponent,
51
+ BinaryDownloadComponent,
50
52
  C8yTranslateDirective,
51
53
  C8yTranslatePipe
52
54
  ]
@@ -65,7 +67,9 @@ export class LoginComponent implements OnInit, OnDestroy {
65
67
  loginViewParams: CredentialsComponentParams | { [key: string]: any } = {};
66
68
  recoverPasswordData: LoginEvent['recoverPasswordData'];
67
69
  displayAlerts = false;
68
- private TOKEN_PARAM = 'token';
70
+ downloadBinaryId: string | null = null;
71
+
72
+ private readonly TOKEN_PARAM = 'token';
69
73
 
70
74
  /**
71
75
  * Just DI.
@@ -82,6 +86,8 @@ export class LoginComponent implements OnInit, OnDestroy {
82
86
  }
83
87
 
84
88
  async ngOnInit() {
89
+ this.downloadBinaryId = this.loginService.initDownloadBinaryId();
90
+
85
91
  const token = this.getParamAndClear(this.TOKEN_PARAM);
86
92
  const ssoData = this.getSsoData();
87
93
  if (ssoData) {
@@ -103,6 +109,10 @@ export class LoginComponent implements OnInit, OnDestroy {
103
109
  }
104
110
 
105
111
  handleLoginTemplate(event: LoginEvent) {
112
+ if (event.view === LoginViews.Download) {
113
+ this.enterDownloadView();
114
+ return;
115
+ }
106
116
  this.currentView = event.view;
107
117
  this.credentials = event.credentials || {};
108
118
  this.loginViewParams = event.loginViewParams || {};
@@ -117,7 +127,7 @@ export class LoginComponent implements OnInit, OnDestroy {
117
127
 
118
128
  async reset(missingPermissions: boolean) {
119
129
  if (missingPermissions) {
120
- this.handleLoginTemplate({ view: LoginViews.MissingApplicationAccess });
130
+ this.handleLoginTemplate({ view: this.loginService.getViewForSuppressedRedirect() });
121
131
  return;
122
132
  }
123
133
  this.loginService.reset();
@@ -208,6 +218,11 @@ export class LoginComponent implements OnInit, OnDestroy {
208
218
  }
209
219
  }
210
220
 
221
+ private enterDownloadView() {
222
+ this.currentView = LoginViews.Download;
223
+ this.loginService.clearPendingDownload();
224
+ }
225
+
211
226
  private getParamAndClear(paramName: string): string | undefined {
212
227
  const paramValue = this.options.get<string>(paramName);
213
228
  if (paramValue) {
@@ -241,6 +256,11 @@ export class LoginComponent implements OnInit, OnDestroy {
241
256
  this.loginService
242
257
  .loginBySso(ssoData)
243
258
  .then(() => this.loginService.login())
259
+ .then(hasRedirected => {
260
+ if (!hasRedirected && this.downloadBinaryId) {
261
+ this.enterDownloadView();
262
+ }
263
+ })
244
264
  .catch(e => {
245
265
  this.reset(false);
246
266
  if (e.res?.status) {
@@ -27,7 +27,8 @@ export enum LoginViews {
27
27
  TotpSetup = 'TOTP_SETUP',
28
28
  ProvidePhoneNumber = 'PROVIDE_PHONE_NUMBER',
29
29
  TenantIdSetup = 'TENANT_ID_SETUP',
30
- MissingApplicationAccess = 'MISSING_APPLICATION_ACCESS'
30
+ MissingApplicationAccess = 'MISSING_APPLICATION_ACCESS',
31
+ Download = 'DOWNLOAD'
31
32
  }
32
33
 
33
34
  export type SsoData = {
@@ -21,7 +21,8 @@ import { switchMap } from 'rxjs/operators';
21
21
  import { BehaviorSubject, EMPTY } from 'rxjs';
22
22
  import { isEmpty } from 'lodash-es';
23
23
  import { TranslateService } from '@ngx-translate/core';
24
- import { LoginEvent, SsoData } from './login.model';
24
+ import { Router } from '@angular/router';
25
+ import { LoginEvent, LoginViews, SsoData } from './login.model';
25
26
  import { getStoredToken, getStoredTfaToken, TOKEN_KEY, TFATOKEN_KEY } from '@c8y/bootstrap';
26
27
 
27
28
  /**
@@ -34,6 +35,11 @@ export class LoginService extends SimplifiedAuthService {
34
35
  oauthOptions: ITenantLoginOption;
35
36
  isFirstLogin = true;
36
37
  automaticLoginInProgress$ = new BehaviorSubject(true);
38
+ get pendingDownloadBinaryId(): string | null {
39
+ return this._pendingDownloadBinaryId;
40
+ }
41
+
42
+ private _pendingDownloadBinaryId: string | null = null;
37
43
 
38
44
  regexp = /\/apps\/(public\/)?([^\/]+)(\/.*)?$/;
39
45
 
@@ -49,6 +55,8 @@ export class LoginService extends SimplifiedAuthService {
49
55
  private readonly IDP_HINT_QUERY_PARAM = 'idp_hint';
50
56
  private readonly PASSWORD_MAX_LENGTH = 32;
51
57
  private readonly PASSWORD_ALLOWED_SYMBOLS = '`~!@#$%^&*()_|+-=?;:\'",.<>{}[]\\/';
58
+ private readonly DOWNLOAD_PARAM = 'download';
59
+ private readonly DOWNLOAD_SESSION_KEY = 'c8yPendingDownloadId';
52
60
  private translateService = inject(TranslateService);
53
61
  ERROR_MESSAGES = {
54
62
  minlength: gettext('Password must have at least 8 characters and no more than 32.'),
@@ -97,6 +105,7 @@ export class LoginService extends SimplifiedAuthService {
97
105
  private modalService = inject(ModalService);
98
106
  private applicationService = inject(ApplicationService);
99
107
  private document = inject(DOCUMENT);
108
+ private router = inject(Router);
100
109
 
101
110
  constructor() {
102
111
  super();
@@ -337,7 +346,68 @@ export class LoginService extends SimplifiedAuthService {
337
346
  return this.ensureUserPermissionsForRedirect(user);
338
347
  }
339
348
 
349
+ /**
350
+ * Reads the binary download ID from the URL (hash fragment) or from
351
+ * sessionStorage (SSO return path). Clears it from the URL immediately and persists it in
352
+ * sessionStorage so it survives the OAuth redirect. Sets pendingDownloadBinaryId so that
353
+ * the post-auth redirect is suppressed when a download is pending.
354
+ * @returns The binary ID, or null when no download param is present.
355
+ */
356
+ initDownloadBinaryId(): string | null {
357
+ const stored = sessionStorage.getItem(this.DOWNLOAD_SESSION_KEY);
358
+
359
+ // Download links are always in the hash fragment (#/?download=<id>).
360
+ const binaryId =
361
+ this.router.parseUrl(this.document.location.hash.substring(1)).queryParams[
362
+ this.DOWNLOAD_PARAM
363
+ ] ?? null;
364
+
365
+ // Common case: no download param in URL or session — return early
366
+ if (!binaryId && !stored) {
367
+ return null;
368
+ }
369
+
370
+ if (binaryId) {
371
+ // Remove the download param from the URL without adding a history entry
372
+ this.router.navigate([], {
373
+ queryParams: { [this.DOWNLOAD_PARAM]: null },
374
+ queryParamsHandling: 'merge',
375
+ replaceUrl: true
376
+ });
377
+ // Replace any stale entry and persist the id across a potential SSO redirect
378
+ sessionStorage.removeItem(this.DOWNLOAD_SESSION_KEY);
379
+ sessionStorage.setItem(this.DOWNLOAD_SESSION_KEY, binaryId);
380
+ this._pendingDownloadBinaryId = binaryId;
381
+ return binaryId;
382
+ }
383
+
384
+ // SSO return: param no longer in URL but bridged via sessionStorage
385
+ sessionStorage.removeItem(this.DOWNLOAD_SESSION_KEY);
386
+ this._pendingDownloadBinaryId = stored;
387
+ return stored;
388
+ }
389
+
390
+ clearPendingDownload() {
391
+ this._pendingDownloadBinaryId = null;
392
+ sessionStorage.removeItem(this.DOWNLOAD_SESSION_KEY);
393
+ }
394
+
395
+ /**
396
+ * Resolves which view a caller should show once login()/ensureUserPermissionsForRedirect()
397
+ * reports that no redirect happened. A pending download always wins over the generic
398
+ * missing-access view, regardless of whether the user actually has application access,
399
+ * since the user came here to download a shared file, not to open an app.
400
+ */
401
+ getViewForSuppressedRedirect(): LoginViews {
402
+ return this.pendingDownloadBinaryId ? LoginViews.Download : LoginViews.MissingApplicationAccess;
403
+ }
404
+
340
405
  async ensureUserPermissionsForRedirect(user: IUser | ICurrentUser) {
406
+ // A pending download requires the user to stay on the login page.
407
+ // Suppress the post-auth redirect so the download view is shown instead.
408
+ if (this.pendingDownloadBinaryId) {
409
+ return false;
410
+ }
341
411
  const redirectPath = await this.getRedirectPath();
342
412
  if (!redirectPath) {
343
413
  return false;
@@ -12,7 +12,7 @@ import {
12
12
  import { gettext } from '@c8y/ngx-components/gettext';
13
13
  import { FormsModule } from '@angular/forms';
14
14
  import { NgClass } from '@angular/common';
15
- import { LoginEvent, LoginViews } from '../login.model';
15
+ import { LoginEvent } from '../login.model';
16
16
 
17
17
  @Component({
18
18
  selector: 'c8y-sms-challenge',
@@ -86,7 +86,7 @@ export class SmsChallengeComponent {
86
86
  this.appState.currentUser.value
87
87
  );
88
88
  if (!result) {
89
- this.onChangeView.emit({ view: LoginViews.MissingApplicationAccess });
89
+ this.onChangeView.emit({ view: this.loginService.getViewForSuppressedRedirect() });
90
90
  }
91
91
  } catch (e) {
92
92
  const resStatus = e.res && e.res.status;
@@ -5,8 +5,9 @@
5
5
  Two-factor authentication
6
6
  </div>
7
7
 
8
- <c8y-totp-setup *ngIf="isSetup">
9
- </c8y-totp-setup>
8
+ @if (isSetup) {
9
+ <c8y-totp-setup> </c8y-totp-setup>
10
+ }
10
11
  <c8y-totp-challenge
11
12
  [isModal]="false"
12
13
  [loading]="loading"
@@ -15,4 +16,3 @@
15
16
  (onSuccess)="onTotpSuccess($event)"
16
17
  (totpUnconfirmedEmitter)="onCancel.emit()"
17
18
  ></c8y-totp-challenge>
18
-
@@ -10,13 +10,12 @@ import {
10
10
  import { LoginService } from '../login.service';
11
11
  import { LoginEvent, LoginViews } from '../login.model';
12
12
  import { gettext } from '@c8y/ngx-components/gettext';
13
- import { NgIf } from '@angular/common';
14
13
 
15
14
  @Component({
16
15
  selector: 'c8y-totp-auth',
17
16
  templateUrl: './totp-auth.component.html',
18
17
  standalone: true,
19
- imports: [C8yTranslateDirective, NgIf, TotpSetupComponent, TotpChallengeComponent]
18
+ imports: [C8yTranslateDirective, TotpSetupComponent, TotpChallengeComponent]
20
19
  })
21
20
  export class TotpAuthComponent implements OnInit {
22
21
  @Input() credentials: ICredentials;
@@ -55,7 +54,7 @@ export class TotpAuthComponent implements OnInit {
55
54
  this.appState.currentUser.value
56
55
  );
57
56
  if (!result) {
58
- this.onChangeView.emit({ view: LoginViews.MissingApplicationAccess });
57
+ this.onChangeView.emit({ view: this.loginService.getViewForSuppressedRedirect() });
59
58
  }
60
59
  } catch (e) {
61
60
  this.alert.removeLastDanger();