@rdlabo/ionic-angular-kit 0.0.1 → 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 +38 -9
- 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 -709
- package/fesm2022/rdlabo-ionic-angular-kit.mjs.map +0 -1
- package/types/rdlabo-ionic-angular-kit.d.ts +0 -684
package/README.md
CHANGED
|
@@ -265,31 +265,36 @@ A fleet-canonical HTTP interceptor with:
|
|
|
265
265
|
|
|
266
266
|
**Convention:** all app-specific logic (auth headers, error UI) lives in the config factory. The retry policy, bypass evaluation, and error dispatch are fixed in the kit and not overridable per-call.
|
|
267
267
|
|
|
268
|
+
**Only `getAuthHeaders` is required.** Every other hook is optional and defaults to a safe no-op (`buildExtraHeaders` → `{}`, `bypass` → `false`, `offlineFallback` → `null`), so a config specifies only the behavior that actually differs from the baseline.
|
|
269
|
+
|
|
268
270
|
**Setup**
|
|
269
271
|
|
|
270
272
|
```typescript
|
|
271
273
|
// app.config.ts
|
|
272
274
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
|
273
|
-
import { kitAuthInterceptor, provideKitHttp } from '@rdlabo/ionic-angular-kit';
|
|
275
|
+
import { kitAuthInterceptor, provideKitHttp, KitReloadAlertController } from '@rdlabo/ionic-angular-kit';
|
|
274
276
|
|
|
275
277
|
export const appConfig: ApplicationConfig = {
|
|
276
278
|
providers: [
|
|
277
279
|
provideHttpClient(withInterceptors([kitAuthInterceptor])),
|
|
278
280
|
provideKitHttp(() => {
|
|
279
281
|
const auth = inject(AuthService);
|
|
280
|
-
const
|
|
281
|
-
const toast = inject(KitOverlayController);
|
|
282
|
+
const reload = inject(KitReloadAlertController);
|
|
282
283
|
return {
|
|
283
|
-
bypass: (req) => req.url.startsWith('https://cdn.example.com'),
|
|
284
284
|
getAuthHeaders: async (req) => ({
|
|
285
285
|
Authorization: `Bearer ${await auth.getToken()}`,
|
|
286
286
|
}),
|
|
287
|
-
buildExtraHeaders: (req) => ({ 'X-App-Version': '1.0.0' }),
|
|
288
|
-
offlineFallback: (req, err) => null, // no offline queue
|
|
289
287
|
onUnauthorized: (req) => auth.signOut(),
|
|
290
|
-
|
|
291
|
-
onNetworkError: (status) =>
|
|
292
|
-
|
|
288
|
+
// Fleet-canonical "network error → offer reload" (see KitReloadAlertController).
|
|
289
|
+
onNetworkError: (status) =>
|
|
290
|
+
reload.present({
|
|
291
|
+
header: 'ネットワークエラー',
|
|
292
|
+
message: `通信できませんでした。リフレッシュしますか?(${status})`,
|
|
293
|
+
okText: 'リフレッシュ',
|
|
294
|
+
}),
|
|
295
|
+
// Auto-dismiss the stale alert once connectivity is back.
|
|
296
|
+
onResponse: () => void reload.dismiss(),
|
|
297
|
+
// buildExtraHeaders / bypass / offlineFallback / onForbidden / onServerError omitted → kit defaults.
|
|
293
298
|
};
|
|
294
299
|
}),
|
|
295
300
|
],
|
|
@@ -303,6 +308,30 @@ export const appConfig: ApplicationConfig = {
|
|
|
303
308
|
4. Non-400/500 status AND device connected → `onNetworkError`
|
|
304
309
|
5. 400 or 500 with `error.message` → `onServerError`
|
|
305
310
|
|
|
311
|
+
### KitReloadAlertController
|
|
312
|
+
|
|
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).
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import { KitReloadAlertController } from '@rdlabo/ionic-angular-kit';
|
|
324
|
+
|
|
325
|
+
const reload = inject(KitReloadAlertController);
|
|
326
|
+
await reload.present({
|
|
327
|
+
header: 'ネットワークエラー',
|
|
328
|
+
message: `通信できませんでした。リフレッシュしますか?(${status})`,
|
|
329
|
+
okText: 'リフレッシュ',
|
|
330
|
+
});
|
|
331
|
+
// later, on a successful response:
|
|
332
|
+
await reload.dismiss();
|
|
333
|
+
```
|
|
334
|
+
|
|
306
335
|
---
|
|
307
336
|
|
|
308
337
|
### KitAutofillDirective
|
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
|
+
}
|