@structured-id/ui-core 1.3.0 → 3.0.0

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.
Files changed (152) hide show
  1. package/README.md +22 -10
  2. package/package.json +25 -43
  3. package/src/components/SidStatusBadge.vue +1 -5
  4. package/src/composables/useAccountTransport.test.ts +2 -1
  5. package/src/composables/useGrpc.test.ts +17 -14
  6. package/src/composables/useGrpc.ts +4 -2
  7. package/src/index.ts +2 -120
  8. package/src/profile/components/ConsentManager.vue +5 -1
  9. package/src/profile/components/ProfileEditor.vue +1 -1
  10. package/src/profile/components/SecuritySettings.vue +5 -1
  11. package/src/profile/components/SessionList.vue +2 -1
  12. package/src/profile/components/SidRegistrationForm.test.ts +173 -1
  13. package/src/profile/components/SidRegistrationForm.vue +88 -32
  14. package/src/profile/components/index.ts +1 -4
  15. package/src/profile/composables/index.ts +1 -0
  16. package/src/profile/composables/operationPlan.test.ts +155 -0
  17. package/src/profile/composables/operationPlan.ts +218 -0
  18. package/src/profile/composables/useAccountApi.ts +4 -4
  19. package/src/profile/composables/useAuth.test.ts +312 -31
  20. package/src/profile/composables/useAuth.ts +176 -30
  21. package/src/profile/composables/useAuthApi.opaque.test.ts +13 -6
  22. package/src/profile/composables/useAuthApi.ts +40 -18
  23. package/src/profile/composables/useCurrentPasswordRequirement.test.ts +196 -0
  24. package/src/profile/composables/useCurrentPasswordRequirement.ts +95 -0
  25. package/src/profile/composables/useEnrollmentApi.test.ts +7 -3
  26. package/src/profile/composables/useEnrollmentApi.ts +3 -5
  27. package/src/profile/composables/useIdentityApi.ts +4 -4
  28. package/src/profile/composables/useSecurityLevel.test.ts +1 -1
  29. package/src/profile/composables/useSecurityLevel.ts +1 -1
  30. package/src/quasar/SidPasswordCeremony.test.ts +253 -0
  31. package/src/quasar/SidPasswordCeremony.vue +456 -0
  32. package/src/quasar/index.ts +2 -0
  33. package/src/utils/rpcRefusal.test.ts +4 -4
  34. package/src/utils/rpcRefusal.ts +8 -4
  35. package/dist/browser-vp/index.cjs +0 -349
  36. package/dist/browser-vp/index.d.cts +0 -276
  37. package/dist/browser-vp/index.d.ts +0 -276
  38. package/dist/browser-vp/index.js +0 -349
  39. package/dist/chunk-C3Q766CY.cjs +0 -125
  40. package/dist/chunk-J5YXRXB5.js +0 -125
  41. package/dist/index.cjs +0 -23581
  42. package/dist/index.d.cts +0 -11539
  43. package/dist/index.d.ts +0 -11539
  44. package/dist/index.js +0 -23581
  45. package/dist/storage-4CB2JTLW.cjs +0 -22
  46. package/dist/storage-THRWREZP.js +0 -22
  47. package/src/browser-vp/crypto.ts +0 -253
  48. package/src/browser-vp/index.test.ts +0 -10
  49. package/src/browser-vp/index.ts +0 -116
  50. package/src/browser-vp/prf.ts +0 -179
  51. package/src/browser-vp/storage.test.ts +0 -9
  52. package/src/browser-vp/storage.ts +0 -163
  53. package/src/browser-vp/types.ts +0 -95
  54. package/src/browser-vp/vp.test.ts +0 -43
  55. package/src/browser-vp/vp.ts +0 -111
  56. package/src/generated/google/api/http.ts +0 -687
  57. package/src/generated/google/api/httpbody.ts +0 -159
  58. package/src/generated/google/protobuf/any.ts +0 -326
  59. package/src/generated/google/protobuf/descriptor.ts +0 -4865
  60. package/src/generated/google/protobuf/duration.ts +0 -230
  61. package/src/generated/google/protobuf/empty.ts +0 -94
  62. package/src/generated/google/protobuf/struct.ts +0 -484
  63. package/src/generated/google/protobuf/timestamp.ts +0 -287
  64. package/src/generated/google/rpc/error_details.ts +0 -1386
  65. package/src/generated/google/rpc/status.ts +0 -127
  66. package/src/generated/sid/v1/account/account.client.ts +0 -527
  67. package/src/generated/sid/v1/account/account.ts +0 -3261
  68. package/src/generated/sid/v1/admin/admin.client.ts +0 -139
  69. package/src/generated/sid/v1/admin/admin.ts +0 -781
  70. package/src/generated/sid/v1/admin/admin_roles.client.ts +0 -181
  71. package/src/generated/sid/v1/admin/admin_roles.ts +0 -1606
  72. package/src/generated/sid/v1/admin/analytics.client.ts +0 -106
  73. package/src/generated/sid/v1/admin/analytics.ts +0 -1017
  74. package/src/generated/sid/v1/admin/branding.client.ts +0 -166
  75. package/src/generated/sid/v1/admin/branding.ts +0 -1906
  76. package/src/generated/sid/v1/admin/enrollment.client.ts +0 -177
  77. package/src/generated/sid/v1/admin/enrollment.ts +0 -1766
  78. package/src/generated/sid/v1/admin/events.client.ts +0 -195
  79. package/src/generated/sid/v1/admin/events.ts +0 -1900
  80. package/src/generated/sid/v1/admin/flow.client.ts +0 -183
  81. package/src/generated/sid/v1/admin/flow.ts +0 -1552
  82. package/src/generated/sid/v1/admin/keys.client.ts +0 -107
  83. package/src/generated/sid/v1/admin/keys.ts +0 -931
  84. package/src/generated/sid/v1/admin/login_experience.client.ts +0 -160
  85. package/src/generated/sid/v1/admin/login_experience.ts +0 -1061
  86. package/src/generated/sid/v1/admin/migration.client.ts +0 -127
  87. package/src/generated/sid/v1/admin/migration.ts +0 -1948
  88. package/src/generated/sid/v1/admin/notifications.client.ts +0 -134
  89. package/src/generated/sid/v1/admin/notifications.ts +0 -1273
  90. package/src/generated/sid/v1/admin/operations.client.ts +0 -241
  91. package/src/generated/sid/v1/admin/operations.ts +0 -2633
  92. package/src/generated/sid/v1/admin/organizations.client.ts +0 -57
  93. package/src/generated/sid/v1/admin/organizations.ts +0 -332
  94. package/src/generated/sid/v1/admin/provisioning.client.ts +0 -232
  95. package/src/generated/sid/v1/admin/provisioning.ts +0 -1383
  96. package/src/generated/sid/v1/admin/scopes.client.ts +0 -171
  97. package/src/generated/sid/v1/admin/scopes.ts +0 -1324
  98. package/src/generated/sid/v1/admin/security.client.ts +0 -410
  99. package/src/generated/sid/v1/admin/security.ts +0 -4077
  100. package/src/generated/sid/v1/admin/sessions.client.ts +0 -114
  101. package/src/generated/sid/v1/admin/sessions.ts +0 -923
  102. package/src/generated/sid/v1/admin/settings.client.ts +0 -351
  103. package/src/generated/sid/v1/admin/settings.ts +0 -2458
  104. package/src/generated/sid/v1/admin/user_profile.client.ts +0 -57
  105. package/src/generated/sid/v1/admin/user_profile.ts +0 -730
  106. package/src/generated/sid/v1/admin/webhooks.client.ts +0 -269
  107. package/src/generated/sid/v1/admin/webhooks.ts +0 -2804
  108. package/src/generated/sid/v1/attestation/attestation.client.ts +0 -115
  109. package/src/generated/sid/v1/attestation/attestation.ts +0 -813
  110. package/src/generated/sid/v1/authn/auth.client.ts +0 -943
  111. package/src/generated/sid/v1/authn/auth.ts +0 -9719
  112. package/src/generated/sid/v1/authn/flows.client.ts +0 -413
  113. package/src/generated/sid/v1/authn/flows.ts +0 -3205
  114. package/src/generated/sid/v1/authn/issuer.client.ts +0 -489
  115. package/src/generated/sid/v1/authn/issuer.ts +0 -1600
  116. package/src/generated/sid/v1/authn/password_history.client.ts +0 -57
  117. package/src/generated/sid/v1/authn/password_history.ts +0 -578
  118. package/src/generated/sid/v1/authn/system_integration.client.ts +0 -73
  119. package/src/generated/sid/v1/authn/system_integration.ts +0 -218
  120. package/src/generated/sid/v1/authz/authz.client.ts +0 -459
  121. package/src/generated/sid/v1/authz/authz.ts +0 -6524
  122. package/src/generated/sid/v1/authz/forward_auth.client.ts +0 -97
  123. package/src/generated/sid/v1/authz/forward_auth.ts +0 -79
  124. package/src/generated/sid/v1/authz/governance.client.ts +0 -102
  125. package/src/generated/sid/v1/authz/governance.ts +0 -875
  126. package/src/generated/sid/v1/common/errors.ts +0 -710
  127. package/src/generated/sid/v1/common/pagination.ts +0 -152
  128. package/src/generated/sid/v1/common/transport.ts +0 -227
  129. package/src/generated/sid/v1/common/types.ts +0 -151
  130. package/src/generated/sid/v1/events/event_stream.client.ts +0 -67
  131. package/src/generated/sid/v1/events/event_stream.ts +0 -375
  132. package/src/generated/sid/v1/federation/upstream.client.ts +0 -142
  133. package/src/generated/sid/v1/federation/upstream.ts +0 -1736
  134. package/src/generated/sid/v1/identity/identity.client.ts +0 -467
  135. package/src/generated/sid/v1/identity/identity.ts +0 -5676
  136. package/src/generated/sid/v1/ids/ids.ts +0 -395
  137. package/src/generated/sid/v1/machine/machine_user.client.ts +0 -219
  138. package/src/generated/sid/v1/machine/machine_user.ts +0 -2364
  139. package/src/generated/sid/v1/machine/pat.client.ts +0 -130
  140. package/src/generated/sid/v1/machine/pat.ts +0 -1083
  141. package/src/generated/sid/v1/projects/projects.client.ts +0 -404
  142. package/src/generated/sid/v1/projects/projects.ts +0 -4446
  143. package/src/generated/sid/v1/scim/scim.client.ts +0 -520
  144. package/src/generated/sid/v1/scim/scim.ts +0 -3182
  145. package/src/generated/sid/v1/test/test.client.ts +0 -232
  146. package/src/generated/sid/v1/test/test.ts +0 -1136
  147. package/src/transport/adaptive.test.ts +0 -728
  148. package/src/transport/adaptive.ts +0 -308
  149. package/src/transport/framing.test.ts +0 -227
  150. package/src/transport/framing.ts +0 -98
  151. package/src/transport/webtransport.test.ts +0 -728
  152. package/src/transport/webtransport.ts +0 -323
@@ -31,7 +31,7 @@
31
31
  </q-banner>
32
32
 
33
33
  <q-banner
34
- v-if="error"
34
+ v-if="error && !ceremony"
35
35
  class="bg-negative text-white q-mb-md"
36
36
  rounded
37
37
  dense
@@ -40,7 +40,7 @@
40
40
  </q-banner>
41
41
 
42
42
  <q-banner
43
- v-if="success"
43
+ v-if="success && !ceremony"
44
44
  class="bg-positive text-white q-mb-md"
45
45
  rounded
46
46
  dense
@@ -48,21 +48,27 @@
48
48
  {{ successText }}
49
49
  </q-banner>
50
50
 
51
- <slot name="progress" :progress="progress">
52
- <div v-if="progress" class="q-mb-md">
53
- <q-linear-progress
54
- :value="progress.fraction"
55
- color="primary"
56
- class="q-mb-sm"
51
+ <!-- While the operation runs the ceremony takes the fields' place;
52
+ an application's own `progress` slot replaces the ceremony. -->
53
+ <template v-if="ceremony">
54
+ <slot name="progress" :progress="progress">
55
+ <sid-password-ceremony
56
+ :progress="progress"
57
+ :outcome="ceremony"
58
+ :fields="showConfirmPassword ? 2 : 1"
59
+ :length="password.length"
60
+ :accepted-text="acceptedText"
61
+ :refused-text="error ?? undefined"
62
+ @settled="onSettled"
57
63
  />
58
- <div class="text-caption text-center text-grey-7">
59
- <q-icon name="sym_o_lock" size="xs" class="q-mr-xs" />
60
- {{ progress.label }}
61
- </div>
62
- </div>
63
- </slot>
64
+ </slot>
65
+ </template>
64
66
 
65
- <q-form @submit.prevent="onSubmit" class="q-gutter-y-md">
67
+ <q-form
68
+ v-show="!ceremony && !success"
69
+ @submit.prevent="onSubmit"
70
+ class="q-gutter-y-md"
71
+ >
66
72
  <q-input
67
73
  v-if="claimRequired"
68
74
  v-model="claimToken"
@@ -207,8 +213,10 @@
207
213
  </template>
208
214
 
209
215
  <script setup lang="ts">
210
- import { ref, computed, watch } from "vue";
211
- import { SidPrincipalInput } from "../../quasar";
216
+ import { ref, computed, watch, useSlots } from "vue";
217
+ import { SidPasswordCeremony, SidPrincipalInput } from "../../quasar";
218
+ import type { CeremonyOutcome } from "../../quasar";
219
+ import type { PasswordOperationProgress } from "../composables/useAuth";
212
220
 
213
221
  // Minimal QInput shape — we only use validate(). Avoids importing 'quasar' at
214
222
  // type level (it's a peer dep, not present during ui-core typecheck).
@@ -226,13 +234,8 @@ export interface RegistrationExtras {
226
234
  claimToken?: string;
227
235
  }
228
236
 
229
- /** Progress of the registration (the password proof takes seconds): 0..1 and a label. */
230
- export interface RegistrationProgress {
231
- fraction: number;
232
- label: string;
233
- }
234
-
235
237
  void SidPrincipalInput;
238
+ void SidPasswordCeremony;
236
239
 
237
240
  const props = withDefaults(
238
241
  defineProps<{
@@ -295,8 +298,10 @@ const props = withDefaults(
295
298
  identifier: string,
296
299
  password: string,
297
300
  extras: RegistrationExtras,
298
- onProgress?: (p: RegistrationProgress) => void,
301
+ onProgress?: (p: PasswordOperationProgress) => void,
299
302
  ) => Promise<void>;
303
+ /** What the ceremony says once the account is created. */
304
+ acceptedText?: string;
300
305
  /** Min password length (default 12 — NIST SP 800-63B 4th draft baseline). */
301
306
  passwordMinLength?: number;
302
307
  /** Show confirm-password field with match validation. Default true. */
@@ -336,6 +341,7 @@ const props = withDefaults(
336
341
  "This installation has no administrator yet. Enter the claim token from the service log; the account you create becomes its administrator.",
337
342
  showConfirmPassword: true,
338
343
  usernameMinLength: 6,
344
+ acceptedText: "Account created",
339
345
  },
340
346
  );
341
347
 
@@ -344,6 +350,18 @@ const emit = defineEmits<{
344
350
  error: [error: Error];
345
351
  }>();
346
352
 
353
+ defineSlots<{
354
+ header?: () => unknown;
355
+ /** Replaces the ceremony while the operation runs; the form closes when it ends. */
356
+ progress?: (props: { progress: PasswordOperationProgress | null }) => unknown;
357
+ "extra-fields"?: () => unknown;
358
+ links?: () => unknown;
359
+ footer?: () => unknown;
360
+ }>();
361
+ const slots = useSlots();
362
+ /** Longest a `progress` slot holds the final report when no frame paints. */
363
+ const PAINT_FALLBACK_MS = 250;
364
+
347
365
  const identifier = ref("");
348
366
  const principalType = ref<PrincipalType>("unknown");
349
367
  const password = ref("");
@@ -352,7 +370,11 @@ const showPassword = ref(false);
352
370
  const loading = ref(false);
353
371
  const error = ref<string | null>(null);
354
372
  const success = ref(false);
355
- const progress = ref<RegistrationProgress | null>(null);
373
+ const progress = ref<PasswordOperationProgress | null>(null);
374
+ /** The running or closing ceremony; null while the fields are shown. */
375
+ const ceremony = ref<CeremonyOutcome | null>(null);
376
+ /** What the form announces once the ceremony has closed. */
377
+ let announce: (() => void) | undefined;
356
378
 
357
379
  const passwordRef = ref<ValidatableInput | null>(null);
358
380
  const confirmRef = ref<ValidatableInput | null>(null);
@@ -397,6 +419,10 @@ async function onSubmit() {
397
419
 
398
420
  loading.value = true;
399
421
  error.value = null;
422
+ progress.value = null;
423
+ ceremony.value = "running";
424
+ // A phone's keyboard would cover the ceremony.
425
+ (document.activeElement as HTMLElement | null)?.blur?.();
400
426
 
401
427
  try {
402
428
  const normalized = normalizePrincipal(
@@ -409,19 +435,49 @@ async function onSubmit() {
409
435
  await props.registerFn(normalized.value, password.value, extras, (p) => {
410
436
  progress.value = p;
411
437
  });
412
- success.value = true;
413
- emit("success", {
414
- identifier: normalized.value,
415
- principalType: normalized.type,
416
- });
438
+ announce = () => {
439
+ success.value = true;
440
+ emit("success", {
441
+ identifier: normalized.value,
442
+ principalType: normalized.type,
443
+ });
444
+ };
445
+ ceremony.value = "accepted";
417
446
  } catch (e) {
418
447
  success.value = false;
419
448
  const err = e instanceof Error ? e : new Error("Registration failed");
420
449
  error.value = refusalMessage(e, "Registration failed");
421
- emit("error", err);
450
+ announce = () => emit("error", err);
451
+ ceremony.value = "refused";
422
452
  } finally {
423
453
  loading.value = false;
424
- progress.value = null;
425
454
  }
455
+ // A `progress` slot has no closing animation to wait for, but the final
456
+ // report came in this same turn: the slot keeps it for one painted frame
457
+ // (the second animation frame runs after the first has been painted). A
458
+ // background tab may run no frames at all, so a timer settles it then;
459
+ // whichever comes first settles, once.
460
+ if (slots.progress) {
461
+ let settled = false;
462
+ const settle = () => {
463
+ if (settled) return;
464
+ settled = true;
465
+ clearTimeout(fallback);
466
+ onSettled();
467
+ };
468
+ const fallback = setTimeout(settle, PAINT_FALLBACK_MS);
469
+ requestAnimationFrame(() => requestAnimationFrame(settle));
470
+ }
471
+ }
472
+
473
+ /**
474
+ * The ceremony closed and gives way: a refusal to the fields with its
475
+ * reason, an acceptance to the success text. Then the result is told.
476
+ */
477
+ function onSettled() {
478
+ ceremony.value = null;
479
+ progress.value = null;
480
+ announce?.();
481
+ announce = undefined;
426
482
  }
427
483
  </script>
@@ -1,10 +1,7 @@
1
1
  // Slot-based auth forms
2
2
  export { default as SidLoginForm } from "./SidLoginForm.vue";
3
3
  export { default as SidRegistrationForm } from "./SidRegistrationForm.vue";
4
- export type {
5
- RegistrationExtras,
6
- RegistrationProgress,
7
- } from "./SidRegistrationForm.vue";
4
+ export type { RegistrationExtras } from "./SidRegistrationForm.vue";
8
5
  export { default as SidMfaChallenge } from "./SidMfaChallenge.vue";
9
6
  export type { MfaMethod } from "./SidMfaChallenge.vue";
10
7
 
@@ -5,3 +5,4 @@ export * from "./useAccountApi";
5
5
  export * from "./useEnrollmentApi";
6
6
  export * from "./useIdentityApi";
7
7
  export * from "./useSecurityLevel";
8
+ export * from "./useCurrentPasswordRequirement";
@@ -0,0 +1,155 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ CONFIRMED_CHANGE_STEPS,
4
+ DEFAULT_STEP_MS,
5
+ INSTALL_STEPS,
6
+ OperationPlan,
7
+ type PasswordOperationProgress,
8
+ type PasswordOperationStep,
9
+ type PlanEnvironment,
10
+ } from "./operationPlan";
11
+
12
+ /** A clock moved by hand and a store kept in a variable. */
13
+ function environment(stored: string | null = null) {
14
+ const env = {
15
+ t: 0,
16
+ stored,
17
+ now: () => env.t,
18
+ load: () => env.stored,
19
+ save: (value: string) => {
20
+ env.stored = value;
21
+ },
22
+ };
23
+ return env satisfies PlanEnvironment;
24
+ }
25
+
26
+ function planWith(
27
+ stored: string | null = null,
28
+ steps: readonly PasswordOperationStep[] = INSTALL_STEPS,
29
+ ) {
30
+ const env = environment(stored);
31
+ const seen: PasswordOperationProgress[] = [];
32
+ const plan = new OperationPlan((p) => seen.push(p), steps, env);
33
+ return { env, seen, plan };
34
+ }
35
+
36
+ const total = INSTALL_STEPS.reduce((a, s) => a + DEFAULT_STEP_MS[s], 0);
37
+
38
+ describe("OperationPlan", () => {
39
+ // Each step takes the gauge share its expected time takes of the whole,
40
+ // and a report says where the step ends and how long it should take.
41
+ it("gives each step its share of the expected time", () => {
42
+ const { seen, plan } = planWith();
43
+ plan.enter("protect");
44
+ plan.enter("compare");
45
+ const protect = DEFAULT_STEP_MS.protect / total;
46
+ expect(seen[0]).toMatchObject({
47
+ step: "protect",
48
+ fraction: 0,
49
+ remainingMs: DEFAULT_STEP_MS.protect,
50
+ });
51
+ expect(seen[0].until).toBeCloseTo(protect, 10);
52
+ expect(seen[1].fraction).toBeCloseTo(protect, 10);
53
+ });
54
+
55
+ // The prover's progress fills the proof's share, and what is left of the
56
+ // step's time shrinks with it.
57
+ it("fills a step with its own progress", () => {
58
+ const { seen, plan } = planWith();
59
+ plan.enter("prove");
60
+ plan.within(0.5);
61
+ const start = (DEFAULT_STEP_MS.protect + DEFAULT_STEP_MS.compare) / total;
62
+ const width = DEFAULT_STEP_MS.prove / total;
63
+ expect(seen[1].fraction).toBeCloseTo(start + width / 2, 10);
64
+ expect(seen[1].remainingMs).toBe(DEFAULT_STEP_MS.prove / 2);
65
+ });
66
+
67
+ it("never moves backwards", () => {
68
+ const { seen, plan } = planWith();
69
+ plan.enter("prove");
70
+ plan.within(0.6);
71
+ plan.within(0.2);
72
+ expect(seen[2].fraction).toBe(seen[1].fraction);
73
+ });
74
+
75
+ // A finished operation keeps what its steps took, as a moving average, and
76
+ // the next plan on this device starts from it.
77
+ it("learns the device's step times", () => {
78
+ const { env, plan } = planWith();
79
+ plan.enter("protect");
80
+ env.t = 600;
81
+ plan.enter("compare");
82
+ env.t = 1100;
83
+ plan.enter("prove");
84
+ env.t = 12_100; // a slow phone: 11 s of proving
85
+ plan.enter("verify");
86
+ env.t = 14_100;
87
+ plan.finish();
88
+ const learned = JSON.parse(env.stored ?? "{}");
89
+ expect(learned.prove).toBeCloseTo(
90
+ DEFAULT_STEP_MS.prove * 0.6 + 11_000 * 0.4,
91
+ 6,
92
+ );
93
+ expect(learned.verify).toBeCloseTo(
94
+ DEFAULT_STEP_MS.verify * 0.6 + 2000 * 0.4,
95
+ 6,
96
+ );
97
+
98
+ const next = planWith(env.stored);
99
+ next.plan.enter("prove");
100
+ expect(next.seen[0].remainingMs).toBeCloseTo(learned.prove, 6);
101
+ });
102
+
103
+ // A finished operation reports itself complete, so every progress
104
+ // consumer ends at the full gauge rather than where verification began.
105
+ it("reports completion when it finishes", () => {
106
+ const { seen, plan } = planWith();
107
+ plan.enter("verify");
108
+ plan.finish();
109
+ expect(seen.at(-1)).toMatchObject({
110
+ step: "verify",
111
+ fraction: 1,
112
+ until: 1,
113
+ remainingMs: 0,
114
+ });
115
+ });
116
+
117
+ // A change that confirms the current password gives that step its share
118
+ // after the new password's protection (whose request the confirmation is
119
+ // bound to); an operation without it never reports it and keeps its gauge.
120
+ it("adds the confirmation only to the plan that has it", () => {
121
+ const withConfirm = planWith(null, CONFIRMED_CHANGE_STEPS);
122
+ withConfirm.plan.enter("protect");
123
+ withConfirm.plan.enter("confirm");
124
+ const all = total + DEFAULT_STEP_MS.confirm;
125
+ expect(withConfirm.seen[0]).toMatchObject({ step: "protect", fraction: 0 });
126
+ expect(withConfirm.seen[1]).toMatchObject({ step: "confirm" });
127
+ expect(withConfirm.seen[1].fraction).toBeCloseTo(
128
+ DEFAULT_STEP_MS.protect / all,
129
+ 10,
130
+ );
131
+ expect(withConfirm.seen[1].until).toBeCloseTo(
132
+ (DEFAULT_STEP_MS.protect + DEFAULT_STEP_MS.confirm) / all,
133
+ 10,
134
+ );
135
+
136
+ const without = planWith();
137
+ without.plan.enter("confirm");
138
+ without.plan.enter("protect");
139
+ expect(without.seen).toHaveLength(1);
140
+ expect(without.seen[0]).toMatchObject({ step: "protect", fraction: 0 });
141
+ });
142
+
143
+ // A refused operation teaches nothing; an unreadable record is ignored.
144
+ it("ignores a malformed record and learns only from a finished operation", () => {
145
+ const { env, seen, plan } = planWith("{not json");
146
+ plan.enter("protect");
147
+ expect(seen[0].remainingMs).toBe(DEFAULT_STEP_MS.protect);
148
+ env.t = 5000;
149
+ plan.enter("compare");
150
+ expect(env.stored).toBe("{not json");
151
+ const bad = planWith(JSON.stringify({ prove: -1, verify: "x" }));
152
+ bad.plan.enter("prove");
153
+ expect(bad.seen[0].remainingMs).toBe(DEFAULT_STEP_MS.prove);
154
+ });
155
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The time plan of a password operation, so a display shows how much is
3
+ * left: each step takes the share of the gauge its expected duration takes
4
+ * of the whole, the expected durations are what the steps took before on
5
+ * this device (a moving average kept in the browser), and each report says
6
+ * where the current step ends and how long it is expected to last from now.
7
+ * A display then moves at the expected pace between reports instead of
8
+ * jumping at each one.
9
+ */
10
+
11
+ /**
12
+ * What a password operation is doing, as the user can follow it: confirming
13
+ * the current password (a change that proves it), deriving its request on
14
+ * this device, comparing with previous passwords, proving, and finishing
15
+ * (the client's record and the server's check).
16
+ */
17
+ export type PasswordOperationStep =
18
+ "confirm" | "protect" | "compare" | "prove" | "verify";
19
+
20
+ /** Progress of a password operation, for a gauge that tracks time left. */
21
+ export interface PasswordOperationProgress {
22
+ /** The step the operation is in. */
23
+ step?: PasswordOperationStep;
24
+ /** Where the operation is now, 0..1, never moving backwards. */
25
+ fraction: number;
26
+ label: string;
27
+ /** Where the current step ends on the gauge, 0..1. */
28
+ until?: number;
29
+ /** How long the rest of the current step is expected to take, ms. */
30
+ remainingMs?: number;
31
+ }
32
+
33
+ export const STEP_LABEL: Record<PasswordOperationStep, string> = {
34
+ confirm: "Confirming your current password",
35
+ protect: "Protecting your password on this device",
36
+ compare: "Checking it against your previous passwords",
37
+ prove: "Building the proof",
38
+ verify: "Sealing it and checking with the server",
39
+ };
40
+
41
+ /** The steps of installing a password: registration, reset, a change. */
42
+ export const INSTALL_STEPS: readonly PasswordOperationStep[] = [
43
+ "protect",
44
+ "compare",
45
+ "prove",
46
+ "verify",
47
+ ];
48
+
49
+ /**
50
+ * A change that proves the current password: the new password's start
51
+ * comes first, since the confirmation is bound to its request.
52
+ */
53
+ export const CONFIRMED_CHANGE_STEPS: readonly PasswordOperationStep[] = [
54
+ "protect",
55
+ "confirm",
56
+ "compare",
57
+ "prove",
58
+ "verify",
59
+ ];
60
+
61
+ const ALL_STEPS = CONFIRMED_CHANGE_STEPS;
62
+
63
+ /**
64
+ * Step times (ms) before anything was measured here: a mid-range phone
65
+ * (measured on a 2025 one) with the prover's key already prepared.
66
+ */
67
+ export const DEFAULT_STEP_MS: Record<PasswordOperationStep, number> = {
68
+ confirm: 400,
69
+ protect: 150,
70
+ compare: 150,
71
+ prove: 4000,
72
+ verify: 2400,
73
+ };
74
+
75
+ /** Where the measured times live in the browser. */
76
+ const STORE_KEY = "sid:password-operation-ms";
77
+ /** Weight of the newest measurement in the moving average. */
78
+ const ALPHA = 0.4;
79
+
80
+ /** Clock and storage, replaceable in tests. */
81
+ export interface PlanEnvironment {
82
+ now: () => number;
83
+ load: () => string | null;
84
+ save: (value: string) => void;
85
+ }
86
+
87
+ const browser: PlanEnvironment = {
88
+ now: () => performance.now(),
89
+ // Storage can be missing or refuse (private mode): the defaults then apply
90
+ // and nothing is kept, which only costs accuracy.
91
+ load: () => {
92
+ try {
93
+ return globalThis.localStorage?.getItem(STORE_KEY) ?? null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ },
98
+ save: (value) => {
99
+ try {
100
+ globalThis.localStorage?.setItem(STORE_KEY, value);
101
+ } catch {
102
+ // Not kept; the next operation starts from the defaults again.
103
+ }
104
+ },
105
+ };
106
+
107
+ /** The expected step times: measured ones where valid, the defaults otherwise. */
108
+ function expected(env: PlanEnvironment): Record<PasswordOperationStep, number> {
109
+ const out = { ...DEFAULT_STEP_MS };
110
+ try {
111
+ const stored = JSON.parse(env.load() ?? "{}") as Record<string, unknown>;
112
+ for (const step of ALL_STEPS) {
113
+ const ms = stored[step];
114
+ if (typeof ms === "number" && Number.isFinite(ms) && ms > 0)
115
+ out[step] = ms;
116
+ }
117
+ } catch {
118
+ // A malformed record is ignored: defaults apply.
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /**
124
+ * One operation's plan over its `steps`: its reports, and what it measured
125
+ * when it ends.
126
+ */
127
+ export class OperationPlan {
128
+ private readonly ms: Record<PasswordOperationStep, number>;
129
+ private readonly start: Partial<Record<PasswordOperationStep, number>> = {};
130
+ private readonly width: Partial<Record<PasswordOperationStep, number>> = {};
131
+ private readonly measured: Partial<Record<PasswordOperationStep, number>> =
132
+ {};
133
+ private current: PasswordOperationStep | undefined;
134
+ private enteredAt = 0;
135
+ private last = 0;
136
+
137
+ constructor(
138
+ private readonly report?: (p: PasswordOperationProgress) => void,
139
+ steps: readonly PasswordOperationStep[] = INSTALL_STEPS,
140
+ private readonly env: PlanEnvironment = browser,
141
+ ) {
142
+ this.ms = expected(env);
143
+ const total = steps.reduce((sum, s) => sum + this.ms[s], 0);
144
+ let at = 0;
145
+ for (const step of steps) {
146
+ this.start[step] = at;
147
+ this.width[step] = this.ms[step] / total;
148
+ at += this.width[step];
149
+ }
150
+ }
151
+
152
+ /** The operation entered `step`; entering the step it is in changes nothing. */
153
+ enter(step: PasswordOperationStep): void {
154
+ if (this.current === step) return;
155
+ const now = this.env.now();
156
+ this.close(now);
157
+ this.current = step;
158
+ this.enteredAt = now;
159
+ this.emit(step, 0);
160
+ }
161
+
162
+ /** The current step reached `within` (0..1) of its own work. */
163
+ within(within: number): void {
164
+ if (this.current) this.emit(this.current, within);
165
+ }
166
+
167
+ /**
168
+ * The operation succeeded: keep what its steps took for the next plan, and
169
+ * report it complete. The server has already committed it, so an observer
170
+ * that throws on this report is logged and cannot fail the operation.
171
+ */
172
+ finish(): void {
173
+ this.close(this.env.now());
174
+ const next = { ...this.ms };
175
+ for (const step of ALL_STEPS) {
176
+ const took = this.measured[step];
177
+ if (took !== undefined)
178
+ next[step] = next[step] * (1 - ALPHA) + took * ALPHA;
179
+ }
180
+ this.env.save(JSON.stringify(next));
181
+ if (!this.current) return;
182
+ // Exactly 1: the summed step shares can fall short of it by rounding.
183
+ this.last = 1;
184
+ try {
185
+ this.report?.({
186
+ step: this.current,
187
+ fraction: 1,
188
+ label: STEP_LABEL[this.current],
189
+ until: 1,
190
+ remainingMs: 0,
191
+ });
192
+ } catch (e) {
193
+ console.error("A progress observer failed on a committed operation", e);
194
+ }
195
+ }
196
+
197
+ private close(now: number): void {
198
+ if (this.current) this.measured[this.current] = now - this.enteredAt;
199
+ }
200
+
201
+ private emit(step: PasswordOperationStep, within: number): void {
202
+ const start = this.start[step];
203
+ const width = this.width[step];
204
+ // A step outside this plan is a caller's mistake: it reports nothing
205
+ // rather than a position the gauge does not have.
206
+ if (start === undefined || width === undefined) return;
207
+ const done = Math.min(1, Math.max(0, within));
208
+ const fraction = Math.max(this.last, start + width * done);
209
+ this.last = fraction;
210
+ this.report?.({
211
+ step,
212
+ fraction,
213
+ label: STEP_LABEL[step],
214
+ until: start + width,
215
+ remainingMs: this.ms[step] * (1 - done),
216
+ });
217
+ }
218
+ }
@@ -5,9 +5,9 @@
5
5
  * session's Bearer token.
6
6
  * Covers: consents, activity, linked accounts, app launcher, passwordless.
7
7
  */
8
+ import { getAccountTransport } from "../../index";
9
+ import { AccountServiceClient } from "@structured-id/proto/sid/v1/account/account.client";
8
10
  import {
9
- getAccountTransport,
10
- AccountServiceClient,
11
11
  type ConsentInfo,
12
12
  type ClaimConsent,
13
13
  type GetConsentDetailResponse,
@@ -27,8 +27,8 @@ import {
27
27
  ActivityEventStatus,
28
28
  LinkedAccountTrustCategory,
29
29
  LinkedAccountStatus,
30
- Timestamp,
31
- } from "../../index";
30
+ } from "@structured-id/proto/sid/v1/account/account";
31
+ import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
32
32
  import { authMeta } from "../auth";
33
33
 
34
34
  function client(): AccountServiceClient {