@rdlabo/ionic-angular-kit 0.0.2 → 0.0.3
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/README.md +20 -8
- package/ng-package.json +7 -0
- package/package.json +3 -15
- package/src/lib/auth/auth-guards.spec.ts +210 -0
- package/src/lib/auth/auth-guards.ts +227 -0
- package/src/lib/directives/autofill.directive.ts +68 -0
- package/src/lib/http/kit-http.interceptor.spec.ts +300 -0
- package/src/lib/http/kit-http.interceptor.ts +236 -0
- package/src/lib/overlay/kit-overlay.controller.spec.ts +233 -0
- package/src/lib/overlay/kit-overlay.controller.ts +206 -0
- package/src/lib/overlay/kit-reload-alert.controller.spec.ts +105 -0
- package/src/lib/overlay/kit-reload-alert.controller.ts +108 -0
- package/src/lib/overlay/overlay-config.ts +53 -0
- package/src/lib/storage/kit-storage.service.spec.ts +127 -0
- package/src/lib/storage/kit-storage.service.ts +91 -0
- package/src/lib/utils/array.spec.ts +33 -0
- package/src/lib/utils/array.ts +82 -0
- package/src/lib/utils/haptics.ts +32 -0
- package/src/public-api.ts +24 -0
- package/tsconfig.lib.json +14 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +10 -0
- package/fesm2022/rdlabo-ionic-angular-kit.mjs +0 -750
- package/fesm2022/rdlabo-ionic-angular-kit.mjs.map +0 -1
- package/types/rdlabo-ionic-angular-kit.d.ts +0 -733
package/README.md
CHANGED
|
@@ -272,26 +272,28 @@ A fleet-canonical HTTP interceptor with:
|
|
|
272
272
|
```typescript
|
|
273
273
|
// app.config.ts
|
|
274
274
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
275
|
-
import { kitAuthInterceptor, provideKitHttp,
|
|
275
|
+
import { kitAuthInterceptor, provideKitHttp, KitReloadAlertController } from '@rdlabo/ionic-angular-kit';
|
|
276
276
|
|
|
277
277
|
export const appConfig: ApplicationConfig = {
|
|
278
278
|
providers: [
|
|
279
279
|
provideHttpClient(withInterceptors([kitAuthInterceptor])),
|
|
280
280
|
provideKitHttp(() => {
|
|
281
281
|
const auth = inject(AuthService);
|
|
282
|
-
const
|
|
282
|
+
const reload = inject(KitReloadAlertController);
|
|
283
283
|
return {
|
|
284
284
|
getAuthHeaders: async (req) => ({
|
|
285
285
|
Authorization: `Bearer ${await auth.getToken()}`,
|
|
286
286
|
}),
|
|
287
287
|
onUnauthorized: (req) => auth.signOut(),
|
|
288
|
-
// Fleet-canonical "network error → offer reload" (see
|
|
288
|
+
// Fleet-canonical "network error → offer reload" (see KitReloadAlertController).
|
|
289
289
|
onNetworkError: (status) =>
|
|
290
|
-
|
|
290
|
+
reload.present({
|
|
291
291
|
header: 'ネットワークエラー',
|
|
292
292
|
message: `通信できませんでした。リフレッシュしますか?(${status})`,
|
|
293
293
|
okText: 'リフレッシュ',
|
|
294
294
|
}),
|
|
295
|
+
// Auto-dismiss the stale alert once connectivity is back.
|
|
296
|
+
onResponse: () => void reload.dismiss(),
|
|
295
297
|
// buildExtraHeaders / bypass / offlineFallback / onForbidden / onServerError omitted → kit defaults.
|
|
296
298
|
};
|
|
297
299
|
}),
|
|
@@ -306,18 +308,28 @@ export const appConfig: ApplicationConfig = {
|
|
|
306
308
|
4. Non-400/500 status AND device connected → `onNetworkError`
|
|
307
309
|
5. 400 or 500 with `error.message` → `onServerError`
|
|
308
310
|
|
|
309
|
-
###
|
|
311
|
+
### KitReloadAlertController
|
|
310
312
|
|
|
311
|
-
The fleet's canonical "network error → offer to reload"
|
|
313
|
+
The fleet's canonical "network error → offer to reload" alert, as a stateful controller that unifies the good-UX variant that had drifted across apps:
|
|
314
|
+
|
|
315
|
+
- **De-dup** — never stacks; a second `present()` while one is showing is a no-op.
|
|
316
|
+
- **Backdrop lock** — `backdropDismiss: false`, so a critical error isn't dismissed by an accidental backdrop tap.
|
|
317
|
+
- **Auto-dismiss on reconnect** — `dismiss()` (called from a later successful response) clears a now-stale error alert.
|
|
318
|
+
- **Reload on confirm** — the confirm button calls `location.reload()`; cancel uses the configured `labels.cancel`.
|
|
319
|
+
|
|
320
|
+
All text is passed in, so the kit stays free of hardcoded i18n. Wire `present` from a network-class error and `dismiss` from a success (interceptor `onResponse`, or a class interceptor's success path).
|
|
312
321
|
|
|
313
322
|
```typescript
|
|
314
|
-
import {
|
|
323
|
+
import { KitReloadAlertController } from '@rdlabo/ionic-angular-kit';
|
|
315
324
|
|
|
316
|
-
|
|
325
|
+
const reload = inject(KitReloadAlertController);
|
|
326
|
+
await reload.present({
|
|
317
327
|
header: 'ネットワークエラー',
|
|
318
328
|
message: `通信できませんでした。リフレッシュしますか?(${status})`,
|
|
319
329
|
okText: 'リフレッシュ',
|
|
320
330
|
});
|
|
331
|
+
// later, on a successful response:
|
|
332
|
+
await reload.dismiss();
|
|
321
333
|
```
|
|
322
334
|
|
|
323
335
|
---
|
package/ng-package.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rdlabo/ionic-angular-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"peerDependencies": {
|
|
5
5
|
"@angular/common": "^21.0.0",
|
|
6
6
|
"@angular/core": "^21.0.0",
|
|
@@ -16,17 +16,5 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"tslib": "^2.3.0"
|
|
18
18
|
},
|
|
19
|
-
"sideEffects": false
|
|
20
|
-
|
|
21
|
-
"typings": "types/rdlabo-ionic-angular-kit.d.ts",
|
|
22
|
-
"exports": {
|
|
23
|
-
"./package.json": {
|
|
24
|
-
"default": "./package.json"
|
|
25
|
-
},
|
|
26
|
-
".": {
|
|
27
|
-
"types": "./types/rdlabo-ionic-angular-kit.d.ts",
|
|
28
|
-
"default": "./fesm2022/rdlabo-ionic-angular-kit.mjs"
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
"type": "module"
|
|
32
|
-
}
|
|
19
|
+
"sideEffects": false
|
|
20
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { provideZonelessChangeDetection } from '@angular/core';
|
|
2
|
+
import { TestBed } from '@angular/core/testing';
|
|
3
|
+
import type { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
|
|
4
|
+
import { Router } from '@angular/router';
|
|
5
|
+
import { NavController } from '@ionic/angular/standalone';
|
|
6
|
+
import type { Observable } from 'rxjs';
|
|
7
|
+
import { of } from 'rxjs';
|
|
8
|
+
import { firstValueFrom } from 'rxjs';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
type KitAuthState,
|
|
12
|
+
provideKitAuth,
|
|
13
|
+
kitRequiredUnauthorizedGuard,
|
|
14
|
+
kitRequireConfirmingGuard,
|
|
15
|
+
kitRequireAuthorizedGuard,
|
|
16
|
+
} from './auth-guards';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
const REDIRECTS = {
|
|
22
|
+
whenAuthorized: '/home',
|
|
23
|
+
whenConfirming: '/auth/confirm',
|
|
24
|
+
whenNotConfirming: '/auth/signin',
|
|
25
|
+
whenUnauthorized: '/auth',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const routeStub = {} as ActivatedRouteSnapshot;
|
|
29
|
+
const stateStub = {} as RouterStateSnapshot;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The guards always return an Observable at runtime (rxjs pipe).
|
|
33
|
+
* CanActivateFn returns `MaybeAsync<GuardResult>` which widens the compile-time type,
|
|
34
|
+
* so we cast to Observable before handing to firstValueFrom.
|
|
35
|
+
*/
|
|
36
|
+
function runGuard(value: unknown): Promise<boolean | UrlTree> {
|
|
37
|
+
return firstValueFrom(value as Observable<boolean | UrlTree>);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Cast a vi.fn() mock so it satisfies a typed function signature. */
|
|
41
|
+
function mockFn<T>(): T {
|
|
42
|
+
return vi.fn() as unknown as T;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setup(
|
|
46
|
+
state: KitAuthState,
|
|
47
|
+
{
|
|
48
|
+
onAuthorized = vi.fn().mockResolvedValue(true) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>,
|
|
49
|
+
onUnauthenticated = vi.fn().mockResolvedValue(false) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>,
|
|
50
|
+
}: {
|
|
51
|
+
onAuthorized?: (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
52
|
+
onUnauthenticated?: (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
53
|
+
} = {},
|
|
54
|
+
) {
|
|
55
|
+
const navigate = vi.fn().mockResolvedValue(true);
|
|
56
|
+
const setDirection = vi.fn();
|
|
57
|
+
|
|
58
|
+
TestBed.configureTestingModule({
|
|
59
|
+
providers: [
|
|
60
|
+
provideZonelessChangeDetection(),
|
|
61
|
+
provideKitAuth(() => ({
|
|
62
|
+
authState: () => of(state),
|
|
63
|
+
onAuthorized,
|
|
64
|
+
onUnauthenticated,
|
|
65
|
+
redirects: REDIRECTS,
|
|
66
|
+
})),
|
|
67
|
+
{ provide: Router, useValue: { navigate } },
|
|
68
|
+
{ provide: NavController, useValue: { setDirection } },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return { navigate, setDirection, onAuthorized, onUnauthenticated };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// kitRequiredUnauthorizedGuard
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
describe('kitRequiredUnauthorizedGuard', () => {
|
|
79
|
+
afterEach(() => TestBed.resetTestingModule());
|
|
80
|
+
|
|
81
|
+
it("'user' → navigates whenAuthorized and returns false", async () => {
|
|
82
|
+
const { navigate, setDirection } = setup('user');
|
|
83
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub)));
|
|
84
|
+
expect(result).toBe(false);
|
|
85
|
+
expect(setDirection).toHaveBeenCalledWith('root');
|
|
86
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenAuthorized]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("'confirm' → navigates whenConfirming and returns false (no setDirection)", async () => {
|
|
90
|
+
const { navigate, setDirection } = setup('confirm');
|
|
91
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub)));
|
|
92
|
+
expect(result).toBe(false);
|
|
93
|
+
expect(setDirection).not.toHaveBeenCalled();
|
|
94
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenConfirming]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("'required' → returns true", async () => {
|
|
98
|
+
setup('required');
|
|
99
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub)));
|
|
100
|
+
expect(result).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("'anonymous' → returns true", async () => {
|
|
104
|
+
setup('anonymous');
|
|
105
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub)));
|
|
106
|
+
expect(result).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// kitRequireConfirmingGuard
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
describe('kitRequireConfirmingGuard', () => {
|
|
114
|
+
afterEach(() => TestBed.resetTestingModule());
|
|
115
|
+
|
|
116
|
+
it("'confirm' → returns true", async () => {
|
|
117
|
+
setup('confirm');
|
|
118
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireConfirmingGuard(routeStub, stateStub)));
|
|
119
|
+
expect(result).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("'anonymous' → navigates whenAuthorized and returns false", async () => {
|
|
123
|
+
const { navigate, setDirection } = setup('anonymous');
|
|
124
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireConfirmingGuard(routeStub, stateStub)));
|
|
125
|
+
expect(result).toBe(false);
|
|
126
|
+
expect(setDirection).toHaveBeenCalledWith('root');
|
|
127
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenAuthorized]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("'required' → navigates whenNotConfirming and returns false", async () => {
|
|
131
|
+
const { navigate, setDirection } = setup('required');
|
|
132
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireConfirmingGuard(routeStub, stateStub)));
|
|
133
|
+
expect(result).toBe(false);
|
|
134
|
+
expect(setDirection).toHaveBeenCalledWith('root');
|
|
135
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenNotConfirming]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("'user' → navigates whenNotConfirming and returns false", async () => {
|
|
139
|
+
const { navigate, setDirection } = setup('user');
|
|
140
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireConfirmingGuard(routeStub, stateStub)));
|
|
141
|
+
expect(result).toBe(false);
|
|
142
|
+
expect(setDirection).toHaveBeenCalledWith('root');
|
|
143
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenNotConfirming]);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// kitRequireAuthorizedGuard
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
describe('kitRequireAuthorizedGuard', () => {
|
|
151
|
+
afterEach(() => TestBed.resetTestingModule());
|
|
152
|
+
|
|
153
|
+
it("'user' → calls onAuthorized with state and returns its value (true)", async () => {
|
|
154
|
+
const onAuthorized = vi.fn().mockResolvedValue(true) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
155
|
+
setup('user', { onAuthorized });
|
|
156
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
157
|
+
expect(result).toBe(true);
|
|
158
|
+
expect(onAuthorized).toHaveBeenCalledWith(stateStub);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("'user' → propagates UrlTree from onAuthorized", async () => {
|
|
162
|
+
const urlTree = { queryParams: {} } as unknown as UrlTree;
|
|
163
|
+
const onAuthorized = vi.fn().mockResolvedValue(urlTree) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
164
|
+
setup('user', { onAuthorized });
|
|
165
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
166
|
+
expect(result).toBe(urlTree);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("'anonymous' → returns true without calling any hook", async () => {
|
|
170
|
+
const onAuthorized = vi.fn() as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
171
|
+
const onUnauthenticated = vi.fn() as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
172
|
+
setup('anonymous', { onAuthorized, onUnauthenticated });
|
|
173
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
174
|
+
expect(result).toBe(true);
|
|
175
|
+
expect(onAuthorized).not.toHaveBeenCalled();
|
|
176
|
+
expect(onUnauthenticated).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("'required' + onUnauthenticated → true → returns true (fallback allows)", async () => {
|
|
180
|
+
const onUnauthenticated = vi.fn().mockResolvedValue(true) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
181
|
+
setup('required', { onUnauthenticated });
|
|
182
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
183
|
+
expect(result).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("'required' + onUnauthenticated → UrlTree → passes UrlTree through", async () => {
|
|
187
|
+
const urlTree = { queryParams: {} } as unknown as UrlTree;
|
|
188
|
+
const onUnauthenticated = vi.fn().mockResolvedValue(urlTree) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
189
|
+
setup('required', { onUnauthenticated });
|
|
190
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
191
|
+
expect(result).toBe(urlTree);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("'required' + onUnauthenticated → false → navigates whenUnauthorized and returns false", async () => {
|
|
195
|
+
const onUnauthenticated = vi.fn().mockResolvedValue(false) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
196
|
+
const { navigate, setDirection } = setup('required', { onUnauthenticated });
|
|
197
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
198
|
+
expect(result).toBe(false);
|
|
199
|
+
expect(setDirection).toHaveBeenCalledWith('root');
|
|
200
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("'confirm' + onUnauthenticated → false → navigates whenUnauthorized and returns false", async () => {
|
|
204
|
+
const onUnauthenticated = vi.fn().mockResolvedValue(false) as unknown as (s: RouterStateSnapshot) => Promise<boolean | UrlTree>;
|
|
205
|
+
const { navigate } = setup('confirm', { onUnauthenticated });
|
|
206
|
+
const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)));
|
|
207
|
+
expect(result).toBe(false);
|
|
208
|
+
expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized]);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { EnvironmentProviders } from '@angular/core';
|
|
2
|
+
import { inject, InjectionToken, makeEnvironmentProviders } from '@angular/core';
|
|
3
|
+
import type { CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router';
|
|
4
|
+
import { Router } from '@angular/router';
|
|
5
|
+
import { NavController } from '@ionic/angular/standalone';
|
|
6
|
+
import type { Observable } from 'rxjs';
|
|
7
|
+
import { map, mergeMap } from 'rxjs/operators';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discriminated set of authentication states the guards react to.
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* The application is responsible for emitting these values through {@link KitAuthConfig.authState}.
|
|
14
|
+
* An application that does not use a value (for example email confirmation) simply never emits it.
|
|
15
|
+
*
|
|
16
|
+
* - `user` — fully authenticated and verified.
|
|
17
|
+
* - `confirm` — awaiting email confirmation.
|
|
18
|
+
* - `required` — not authenticated.
|
|
19
|
+
* - `anonymous` — signed in anonymously; the user can still be guided toward full registration.
|
|
20
|
+
*/
|
|
21
|
+
export type KitAuthState = 'user' | 'confirm' | 'required' | 'anonymous';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Redirect targets (route paths) used by the guards when access is denied.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* Every field is required and must be provided per application, because the guards have no
|
|
28
|
+
* knowledge of the host application's route layout.
|
|
29
|
+
*/
|
|
30
|
+
export interface KitAuthRedirects {
|
|
31
|
+
/** Used by {@link kitRequiredUnauthorizedGuard}: where to navigate when the user is already authenticated (`user`). */
|
|
32
|
+
readonly whenAuthorized: string;
|
|
33
|
+
/** Used by {@link kitRequiredUnauthorizedGuard}: where to navigate when the user is awaiting email confirmation (`confirm`). */
|
|
34
|
+
readonly whenConfirming: string;
|
|
35
|
+
/** Used by {@link kitRequireConfirmingGuard}: where to navigate when the state is not `confirm`. */
|
|
36
|
+
readonly whenNotConfirming: string;
|
|
37
|
+
/** Used by {@link kitRequireAuthorizedGuard}: where to navigate when the state is not `user` and the fallback is not allowed. */
|
|
38
|
+
readonly whenUnauthorized: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Configuration consumed by the authentication guards, injected through {@link provideKitAuth}.
|
|
43
|
+
*
|
|
44
|
+
* @remarks
|
|
45
|
+
* All members are required. The hooks let the host application plug its own auth service and
|
|
46
|
+
* navigation policy into the otherwise fixed guard control flow.
|
|
47
|
+
*/
|
|
48
|
+
export interface KitAuthConfig {
|
|
49
|
+
/**
|
|
50
|
+
* Source of the current authentication state.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* Typically backed by the application's own auth service (for example `AuthService.isAuth()`).
|
|
54
|
+
*
|
|
55
|
+
* @returns A stream of {@link KitAuthState} values.
|
|
56
|
+
*/
|
|
57
|
+
authState(): Observable<KitAuthState>;
|
|
58
|
+
/**
|
|
59
|
+
* Application-specific work that runs in {@link kitRequireAuthorizedGuard} after the state is confirmed to be `user`.
|
|
60
|
+
*
|
|
61
|
+
* @remarks
|
|
62
|
+
* Typical responsibilities include token login, permission checks, terms-of-service acceptance,
|
|
63
|
+
* or restoring a previously requested redirect.
|
|
64
|
+
*
|
|
65
|
+
* @param state - The router state snapshot of the route being activated.
|
|
66
|
+
* @returns `true` to allow activation, or a `UrlTree` to perform a custom redirect.
|
|
67
|
+
*/
|
|
68
|
+
onAuthorized(state: RouterStateSnapshot): Promise<boolean | UrlTree>;
|
|
69
|
+
/**
|
|
70
|
+
* Fallback that runs in {@link kitRequireAuthorizedGuard} when the state is `required` (not authenticated).
|
|
71
|
+
*
|
|
72
|
+
* @remarks
|
|
73
|
+
* For example, attempt an anonymous sign-in and allow the route. Applications that do not need
|
|
74
|
+
* this should pass `async () => false` to fall through to the default redirect.
|
|
75
|
+
*
|
|
76
|
+
* @param state - The router state snapshot of the route being activated.
|
|
77
|
+
* @returns `true` to allow activation, a `UrlTree` for a custom redirect, or `false` to use the default redirect.
|
|
78
|
+
*/
|
|
79
|
+
onUnauthenticated(state: RouterStateSnapshot): Promise<boolean | UrlTree>;
|
|
80
|
+
/** Redirect targets used by the guards. */
|
|
81
|
+
redirects: KitAuthRedirects;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Injection token that carries the {@link KitAuthConfig} to the authentication guards.
|
|
86
|
+
*/
|
|
87
|
+
export const KIT_AUTH_CONFIG = new InjectionToken<KitAuthConfig>('@rdlabo/ionic-angular-kit:auth');
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Wire the authentication guard configuration into the application's dependency injection.
|
|
91
|
+
*
|
|
92
|
+
* @remarks
|
|
93
|
+
* The factory runs inside an injection context, so it may call `inject()` (for example
|
|
94
|
+
* `inject(AuthService)`) to build the configuration.
|
|
95
|
+
*
|
|
96
|
+
* @param configFactory - Factory that returns the {@link KitAuthConfig} for the application.
|
|
97
|
+
* @returns Environment providers to add to the application bootstrap.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* provideKitAuth(() => {
|
|
102
|
+
* const auth = inject(AuthService);
|
|
103
|
+
* return {
|
|
104
|
+
* authState: () => auth.isAuth(),
|
|
105
|
+
* onAuthorized: async () => true,
|
|
106
|
+
* onUnauthenticated: async () => false,
|
|
107
|
+
* redirects: {
|
|
108
|
+
* whenAuthorized: '/',
|
|
109
|
+
* whenConfirming: '/auth/confirm',
|
|
110
|
+
* whenNotConfirming: '/auth/signin',
|
|
111
|
+
* whenUnauthorized: 'auth',
|
|
112
|
+
* },
|
|
113
|
+
* };
|
|
114
|
+
* });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
export const provideKitAuth = (configFactory: () => KitAuthConfig): EnvironmentProviders =>
|
|
118
|
+
makeEnvironmentProviders([{ provide: KIT_AUTH_CONFIG, useFactory: configFactory }]);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Guard that requires the user to be unauthenticated (for example sign-in or sign-up pages).
|
|
122
|
+
*
|
|
123
|
+
* @remarks
|
|
124
|
+
* Allows the `required` and `anonymous` states (an anonymous user is permitted to proceed to a
|
|
125
|
+
* registration page). An authenticated user (`user`) is sent to `whenAuthorized`, and a user
|
|
126
|
+
* awaiting confirmation (`confirm`) is sent to `whenConfirming`.
|
|
127
|
+
*
|
|
128
|
+
* @returns A stream emitting `true` to allow activation, or `false` after triggering a redirect.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```ts
|
|
132
|
+
* const routes: Routes = [{ path: 'signin', component: SigninPage, canActivate: [kitRequiredUnauthorizedGuard] }];
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export const kitRequiredUnauthorizedGuard: CanActivateFn = () => {
|
|
136
|
+
const { authState, redirects } = inject(KIT_AUTH_CONFIG);
|
|
137
|
+
const router = inject(Router);
|
|
138
|
+
const navCtrl = inject(NavController);
|
|
139
|
+
|
|
140
|
+
return authState().pipe(
|
|
141
|
+
map((data) => {
|
|
142
|
+
if (data === 'user') {
|
|
143
|
+
navCtrl.setDirection('root');
|
|
144
|
+
router.navigate([redirects.whenAuthorized]);
|
|
145
|
+
return false;
|
|
146
|
+
} else if (data === 'confirm') {
|
|
147
|
+
router.navigate([redirects.whenConfirming]);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
// 'required' | 'anonymous'
|
|
151
|
+
return true;
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Guard that requires the user to be awaiting email confirmation (`confirm`).
|
|
158
|
+
*
|
|
159
|
+
* @remarks
|
|
160
|
+
* Any other state triggers a redirect: an `anonymous` user is sent to the authenticated area
|
|
161
|
+
* (`whenAuthorized`), and every remaining state is sent to `whenNotConfirming`.
|
|
162
|
+
*
|
|
163
|
+
* @returns A stream emitting `true` to allow activation, or `false` after triggering a redirect.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* const routes: Routes = [{ path: 'confirm', component: ConfirmPage, canActivate: [kitRequireConfirmingGuard] }];
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export const kitRequireConfirmingGuard: CanActivateFn = () => {
|
|
171
|
+
const { authState, redirects } = inject(KIT_AUTH_CONFIG);
|
|
172
|
+
const router = inject(Router);
|
|
173
|
+
const navCtrl = inject(NavController);
|
|
174
|
+
|
|
175
|
+
return authState().pipe(
|
|
176
|
+
map((data) => {
|
|
177
|
+
if (data === 'confirm') {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
navCtrl.setDirection('root');
|
|
181
|
+
router.navigate([data === 'anonymous' ? redirects.whenAuthorized : redirects.whenNotConfirming]);
|
|
182
|
+
return false;
|
|
183
|
+
}),
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Guard that requires the user to be fully authenticated (`user`).
|
|
189
|
+
*
|
|
190
|
+
* @remarks
|
|
191
|
+
* - `user` — runs {@link KitAuthConfig.onAuthorized} (token login, permission checks, and so on).
|
|
192
|
+
* - `anonymous` — allowed as-is, for applications that permit anonymous browsing.
|
|
193
|
+
* - `required` / `confirm` — runs {@link KitAuthConfig.onUnauthenticated}; if it resolves to `false`,
|
|
194
|
+
* the user is redirected to `whenUnauthorized`.
|
|
195
|
+
*
|
|
196
|
+
* @param _route - The activated route snapshot (unused).
|
|
197
|
+
* @param state - The router state snapshot, forwarded to the configuration hooks.
|
|
198
|
+
* @returns A stream emitting the activation result: `true`, a `UrlTree`, or `false` after a redirect.
|
|
199
|
+
*
|
|
200
|
+
* @example
|
|
201
|
+
* ```ts
|
|
202
|
+
* const routes: Routes = [{ path: 'home', component: HomePage, canActivate: [kitRequireAuthorizedGuard] }];
|
|
203
|
+
* ```
|
|
204
|
+
*/
|
|
205
|
+
export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => {
|
|
206
|
+
const { authState, onAuthorized, onUnauthenticated, redirects } = inject(KIT_AUTH_CONFIG);
|
|
207
|
+
const router = inject(Router);
|
|
208
|
+
const navCtrl = inject(NavController);
|
|
209
|
+
|
|
210
|
+
return authState().pipe(
|
|
211
|
+
mergeMap(async (data) => {
|
|
212
|
+
if (data === 'user') {
|
|
213
|
+
return onAuthorized(state);
|
|
214
|
+
}
|
|
215
|
+
if (data === 'anonymous') {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
const fallback = await onUnauthenticated(state);
|
|
219
|
+
if (fallback !== false) {
|
|
220
|
+
return fallback;
|
|
221
|
+
}
|
|
222
|
+
navCtrl.setDirection('root');
|
|
223
|
+
router.navigate([redirects.whenUnauthorized]);
|
|
224
|
+
return false;
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
227
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { OnInit } from '@angular/core';
|
|
2
|
+
import { Directive, ElementRef, inject } from '@angular/core';
|
|
3
|
+
import { Capacitor } from '@capacitor/core';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Work around iOS `ion-input` autofill values not propagating to the Angular form model.
|
|
7
|
+
*
|
|
8
|
+
* On iOS, when the browser autofills an `ion-input` (for example a saved password), the value
|
|
9
|
+
* is written to the underlying native `<input>` element but the corresponding `change` event is
|
|
10
|
+
* not forwarded to the host `ion-input`, so the Angular form control (and `ngModel`) never sees
|
|
11
|
+
* the autofilled value. This directive listens for the first `change` event on the inner input
|
|
12
|
+
* element and mirrors its value back onto the host element, restoring two-way binding.
|
|
13
|
+
*
|
|
14
|
+
* Apply it to any `ion-input` that participates in a form and may be autofilled by attaching the
|
|
15
|
+
* `rdlaboAutofill` attribute.
|
|
16
|
+
*
|
|
17
|
+
* @remarks
|
|
18
|
+
* The directive is a no-op on every platform other than iOS, where the value already propagates
|
|
19
|
+
* correctly. A short timeout is used because `ion-input` creates its inner `<input>` element
|
|
20
|
+
* asynchronously after the host element is initialized.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```html
|
|
24
|
+
* <ion-input rdlaboAutofill type="password" [(ngModel)]="password"></ion-input>
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
@Directive({
|
|
28
|
+
selector: '[rdlaboAutofill]',
|
|
29
|
+
standalone: true,
|
|
30
|
+
})
|
|
31
|
+
export class KitAutofillDirective implements OnInit {
|
|
32
|
+
readonly #el = inject(ElementRef);
|
|
33
|
+
|
|
34
|
+
constructor() {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register the iOS autofill workaround once the directive is initialized.
|
|
38
|
+
*
|
|
39
|
+
* Returns immediately on non-iOS platforms. On iOS, after a short delay it attaches a one-shot,
|
|
40
|
+
* passive `change` listener to the inner `<input>` element that `ion-input` renders, copying the
|
|
41
|
+
* autofilled value back onto the host element so the Angular form model stays in sync. Any error
|
|
42
|
+
* while locating the inner input (for example if the element is not yet present) is swallowed.
|
|
43
|
+
*
|
|
44
|
+
* @returns Nothing.
|
|
45
|
+
*/
|
|
46
|
+
ngOnInit(): void {
|
|
47
|
+
if (Capacitor.getPlatform() !== 'ios') {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
try {
|
|
52
|
+
this.#el.nativeElement.children[0].addEventListener(
|
|
53
|
+
'change',
|
|
54
|
+
(e: Event) => {
|
|
55
|
+
this.#el.nativeElement.value = (e.target as HTMLInputElement).value;
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
capture: false,
|
|
59
|
+
once: true,
|
|
60
|
+
passive: true,
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
/* empty */
|
|
65
|
+
}
|
|
66
|
+
}, 100); // Need some time for the ion-input to create the input element
|
|
67
|
+
}
|
|
68
|
+
}
|