@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 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 router = inject(Router);
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
- onForbidden: (req) => router.navigate(['/403']),
291
- onNetworkError: (status) => toast.presentToast({ message: 'Network error' }),
292
- onServerError: (message) => toast.presentToast({ message }),
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
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/kit",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rdlabo/ionic-angular-kit",
3
- "version": "0.0.1",
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
- "module": "fesm2022/rdlabo-ionic-angular-kit.mjs",
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
+ }