@structured-id/ui-core 3.1.0 → 3.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structured-id/ui-core",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ bffCsrfToken,
4
+ bffLogout,
5
+ checkBffSession,
6
+ initBff,
7
+ } from "./useBffAuth";
8
+
9
+ /** A BFF that answers every request with this response. */
10
+ function bffAnswers(response: unknown) {
11
+ const fetchMock = vi.fn().mockResolvedValue(response);
12
+ vi.stubGlobal("fetch", fetchMock);
13
+ return fetchMock;
14
+ }
15
+
16
+ /** The BFF's sign-out answer: the session ended, with this grant outcome. */
17
+ function ended(grant: string) {
18
+ return { ok: true, status: 200, json: async () => ({ grant }) };
19
+ }
20
+
21
+ describe("bffLogout", () => {
22
+ beforeEach(async () => {
23
+ initBff("");
24
+ bffAnswers({
25
+ ok: true,
26
+ status: 200,
27
+ json: async () => ({ sub: "s", csrf_token: "token-1" }),
28
+ });
29
+ await checkBffSession();
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ });
35
+
36
+ // The page tells a local end of its session from one where the issuer also
37
+ // revoked the grant, so it can say which happened.
38
+ it.each(["revoked", "not_revoked", "none"] as const)(
39
+ "reports the ended session with grant %s",
40
+ async (grant) => {
41
+ const fetchMock = bffAnswers(ended(grant));
42
+ await expect(bffLogout()).resolves.toEqual({ ended: true, grant });
43
+ expect(fetchMock).toHaveBeenCalledWith("/auth/logout", {
44
+ method: "POST",
45
+ credentials: "include",
46
+ headers: { "X-CSRF-Token": "token-1" },
47
+ });
48
+ },
49
+ );
50
+
51
+ // A refused or failed sign-out leaves the BFF session alive: the page must
52
+ // not report it as ended.
53
+ it.each([
54
+ ["refused", { ok: false, status: 403 }],
55
+ ["unavailable", { ok: false, status: 503 }],
56
+ ])("reports a %s sign-out as not ended", async (_name, response) => {
57
+ bffAnswers(response);
58
+ await expect(bffLogout()).resolves.toEqual({ ended: false });
59
+ });
60
+
61
+ it("reports an unreachable BFF as not ended", async () => {
62
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("offline")));
63
+ await expect(bffLogout()).resolves.toEqual({ ended: false });
64
+ });
65
+
66
+ // An answer the page cannot read still came after the BFF ended the
67
+ // session; only the grant's fate is unknown.
68
+ it("reports an unreadable answer as ended with an unknown grant", async () => {
69
+ bffAnswers({
70
+ ok: true,
71
+ status: 200,
72
+ json: async () => {
73
+ throw new SyntaxError("not json");
74
+ },
75
+ });
76
+ await expect(bffLogout()).resolves.toEqual({
77
+ ended: true,
78
+ grant: "unknown",
79
+ });
80
+ });
81
+
82
+ it("reports nothing ended before the BFF is set up", async () => {
83
+ vi.resetModules();
84
+ const fresh = await import("./useBffAuth");
85
+ const fetchMock = bffAnswers(ended("revoked"));
86
+ await expect(fresh.bffLogout()).resolves.toEqual({ ended: false });
87
+ expect(fetchMock).not.toHaveBeenCalled();
88
+ });
89
+
90
+ // Whatever the BFF answers, the page forgets the session's token.
91
+ it("forgets the CSRF token even when the sign-out failed", async () => {
92
+ bffAnswers({ ok: false, status: 503 });
93
+ await bffLogout();
94
+ expect(bffCsrfToken()).toBeNull();
95
+ });
96
+ });
@@ -118,28 +118,55 @@ export function startBffLogin(
118
118
  window.location.href = url;
119
119
  }
120
120
 
121
+ /**
122
+ * What became of the grant behind an ended BFF session: revoked by the
123
+ * issuer, left to expire because the issuer did not revoke it, none held,
124
+ * or unknown when the BFF's answer could not be read.
125
+ */
126
+ export type BffGrantOutcome = "revoked" | "not_revoked" | "none" | "unknown";
127
+
128
+ /** The outcome of a sign-out: whether the BFF ended the session, and its grant. */
129
+ export type BffLogoutResult =
130
+ { ended: true; grant: BffGrantOutcome } | { ended: false };
131
+
132
+ const GRANT_OUTCOMES: readonly BffGrantOutcome[] = [
133
+ "revoked",
134
+ "not_revoked",
135
+ "none",
136
+ ];
137
+
121
138
  /**
122
139
  * Log out by calling POST /auth/logout with X-CSRF-Token header.
123
140
  *
124
- * The BFF validates the CSRF header, clears server-side session,
125
- * and sends a Set-Cookie header to clear the session cookie. The token
126
- * dies with the session either way.
141
+ * The BFF validates the CSRF header, ends the session, clears its cookie and
142
+ * says whether the issuer revoked the grant. `ended: false` means the
143
+ * session may still be alive (refused, unavailable, unreachable). The page
144
+ * forgets the CSRF token either way.
127
145
  *
128
146
  * @param bffBaseUrl Override BFF base URL
129
147
  */
130
- export async function bffLogout(bffBaseUrl?: string): Promise<void> {
148
+ export async function bffLogout(bffBaseUrl?: string): Promise<BffLogoutResult> {
131
149
  const base = bffBaseUrl ?? _bffBaseUrl;
132
- if (base === null) return;
150
+ if (base === null) return { ended: false };
133
151
  const csrf = _csrfToken;
134
152
  _csrfToken = null;
153
+ let resp: Response;
135
154
  try {
136
- await fetch(`${base}/auth/logout`, {
155
+ resp = await fetch(`${base}/auth/logout`, {
137
156
  method: "POST",
138
157
  credentials: "include",
139
158
  headers: csrf ? { "X-CSRF-Token": csrf } : {},
140
159
  });
141
160
  } catch {
142
- // Best-effort — local state is cleared by caller regardless
161
+ return { ended: false };
162
+ }
163
+ if (!resp.ok) return { ended: false };
164
+ try {
165
+ const { grant } = (await resp.json()) as { grant?: unknown };
166
+ const known = GRANT_OUTCOMES.find((outcome) => outcome === grant);
167
+ return { ended: true, grant: known ?? "unknown" };
168
+ } catch {
169
+ return { ended: true, grant: "unknown" };
143
170
  }
144
171
  }
145
172
 
package/src/index.ts CHANGED
@@ -17,6 +17,8 @@ export {
17
17
  bffLogout,
18
18
  bffCsrfToken,
19
19
  type BffUserInfo,
20
+ type BffGrantOutcome,
21
+ type BffLogoutResult,
20
22
  } from "./composables/useBffAuth";
21
23
  export {
22
24
  initAccountApi,
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
2
2
  import { mount } from "@vue/test-utils";
3
3
  import { defineComponent } from "vue";
4
4
  import SidLoginForm from "./SidLoginForm.vue";
5
+ import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
5
6
 
6
7
  // Stub all Quasar components as divs
7
8
  const quasarStubs = {
@@ -16,7 +17,8 @@ const quasarStubs = {
16
17
  emits: ["submit"],
17
18
  },
18
19
  "q-input": defineComponent({
19
- props: ["modelValue", "type", "label", "disable", "rules"],
20
+ name: "QInputStub",
21
+ props: ["modelValue", "type", "label", "disable", "rules", "lazyRules"],
20
22
  emits: ["update:modelValue"],
21
23
  template:
22
24
  '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
@@ -77,4 +79,16 @@ describe("SidLoginForm", () => {
77
79
  const w = mountLogin();
78
80
  expect(w.text()).toContain("Your password never leaves this device");
79
81
  });
82
+
83
+ // Regression: the password turned red on the first keystroke and its empty
84
+ // error read "Required" beside the identifier's "This field is required".
85
+ it("validates the password once left, with the shared required text", () => {
86
+ const w = mountLogin();
87
+ const password = w.findComponent({ name: "QInputStub" });
88
+ const lazy = password.props("lazyRules");
89
+ expect(lazy === "" || lazy === true).toBe(true);
90
+ const [required] = password.props("rules") as ((v: string) => unknown)[];
91
+ expect(required("")).toBe(REQUIRED_FIELD_TEXT);
92
+ expect(required("secret")).toBe(true);
93
+ });
80
94
  });
@@ -48,6 +48,7 @@
48
48
  :label="passwordLabel"
49
49
  outlined
50
50
  :disable="loading"
51
+ lazy-rules
51
52
  :rules="[(v: string) => !!v || passwordRequiredText]"
52
53
  data-test="password"
53
54
  >
@@ -119,6 +120,7 @@ import { ref } from "vue";
119
120
  import { SidPrincipalInput } from "../../quasar";
120
121
  import type { PrincipalType } from "../../quasar";
121
122
  import { normalizePrincipal, refusalMessage } from "../../index";
123
+ import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
122
124
 
123
125
  void SidPrincipalInput;
124
126
 
@@ -169,7 +171,7 @@ const props = withDefaults(
169
171
  submitLabel: "Sign in",
170
172
  identifierLabel: "Email, phone, or username",
171
173
  passwordLabel: "Password",
172
- passwordRequiredText: "Required",
174
+ passwordRequiredText: REQUIRED_FIELD_TEXT,
173
175
  showForgotPassword: true,
174
176
  forgotPasswordText: "Forgot password?",
175
177
  forgotPasswordUrl: undefined,
@@ -225,6 +225,7 @@ interface ValidatableInput {
225
225
  }
226
226
  import type { PrincipalType } from "../../quasar";
227
227
  import { normalizePrincipal, refusalMessage } from "../../index";
228
+ import { REQUIRED_FIELD_TEXT } from "../../utils/validators";
228
229
 
229
230
  /** What the form collected besides the identifier and password. */
230
231
  export interface RegistrationExtras {
@@ -317,7 +318,7 @@ const props = withDefaults(
317
318
  identifierLabel: "Email, phone, or username",
318
319
  passwordLabel: "Password",
319
320
  confirmPasswordLabel: "Confirm password",
320
- passwordRequiredText: "Required",
321
+ passwordRequiredText: REQUIRED_FIELD_TEXT,
321
322
  passwordMinLengthText: "At least 12 characters with mixed case + digit",
322
323
  passwordMinLength: 12,
323
324
  passwordsMismatchText: "Passwords do not match",
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { mount } from "@vue/test-utils";
3
+ import { defineComponent } from "vue";
4
+ import SidPrincipalInput from "./SidPrincipalInput.vue";
5
+ import { REQUIRED_FIELD_TEXT } from "../utils/validators";
6
+
7
+ const stubs = {
8
+ "q-input": defineComponent({
9
+ name: "QInputStub",
10
+ props: ["modelValue", "rules", "lazyRules"],
11
+ template: "<div><slot name='prepend' /><slot name='append' /></div>",
12
+ }),
13
+ "q-icon": { template: "<span />" },
14
+ "q-chip": { template: "<span><slot /></span>" },
15
+ };
16
+
17
+ function input(props: Record<string, unknown> = {}) {
18
+ return mount(SidPrincipalInput, {
19
+ props: { modelValue: "", ...props },
20
+ global: { stubs },
21
+ }).findComponent({ name: "QInputStub" });
22
+ }
23
+
24
+ type Rule = (v: string) => unknown;
25
+
26
+ describe("SidPrincipalInput", () => {
27
+ // Regression: the identifier turned red while still being typed; it is
28
+ // checked once left, unless a form asks for checking from the start.
29
+ it("validates once left by default", () => {
30
+ expect(input().props("lazyRules")).toBe(true);
31
+ expect(input({ lazyRules: false }).props("lazyRules")).toBe(false);
32
+ });
33
+
34
+ // One text for an empty required field, the same the password field uses.
35
+ it("says the shared text of an empty required field", () => {
36
+ const [required] = input().props("rules") as Rule[];
37
+ expect(required("")).toBe(REQUIRED_FIELD_TEXT);
38
+ expect(required("alice")).toBe(true);
39
+ const [custom] = input({ requiredText: "Enter it" }).props(
40
+ "rules",
41
+ ) as Rule[];
42
+ expect(custom("")).toBe("Enter it");
43
+ });
44
+
45
+ it("does not require an optional field", () => {
46
+ const [required] = input({ required: false }).props("rules") as Rule[];
47
+ expect(required("")).toBe(true);
48
+ });
49
+ });
@@ -33,7 +33,7 @@
33
33
  <script setup lang="ts">
34
34
  import { ref, computed, watch } from "vue";
35
35
  import { isValidPhoneNumber } from "libphonenumber-js";
36
- import { isValidEmail } from "../utils/validators";
36
+ import { REQUIRED_FIELD_TEXT, isValidEmail } from "../utils/validators";
37
37
 
38
38
  export type PrincipalType = "email" | "phone" | "username" | "unknown";
39
39
 
@@ -42,7 +42,13 @@ interface Props {
42
42
  minUsernameLength?: number;
43
43
  required?: boolean;
44
44
  label?: string;
45
- /** Validate on each input change (false) vs only on blur (true). Default false — eager. */
45
+ /** What an empty required field says. */
46
+ requiredText?: string;
47
+ /**
48
+ * Validate once the field is left, then on every change (true), or on
49
+ * every change from the first keystroke (false). Default true: a field
50
+ * being typed into is not yet wrong.
51
+ */
46
52
  lazyRules?: boolean;
47
53
  }
48
54
 
@@ -52,7 +58,8 @@ const props = withDefaults(defineProps<Props>(), {
52
58
  minUsernameLength: 3,
53
59
  required: true,
54
60
  label: "Email, phone, or username",
55
- lazyRules: false,
61
+ requiredText: REQUIRED_FIELD_TEXT,
62
+ lazyRules: true,
56
63
  });
57
64
 
58
65
  const emit = defineEmits<{
@@ -107,7 +114,7 @@ function onInput(value: string | number | null) {
107
114
  // --- Validation rules ---
108
115
 
109
116
  const requiredRule = (val: string) =>
110
- !props.required || !!val || "This field is required";
117
+ !props.required || !!val || props.requiredText;
111
118
 
112
119
  const typeRule = (val: string): string | true => {
113
120
  if (!val) return true; // required rule handles empty
@@ -42,6 +42,9 @@ export function maxLength(value: string, max: number): boolean {
42
42
  return value.length <= max;
43
43
  }
44
44
 
45
+ /** What every form says of an empty required field, so one error reads alike. */
46
+ export const REQUIRED_FIELD_TEXT = "This field is required";
47
+
45
48
  /** Check value is not empty/whitespace-only. */
46
49
  export function isRequired(value: string): boolean {
47
50
  return value.trim().length > 0;