@structured-id/ui-core 4.13.0 → 4.14.1

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": "4.13.0",
3
+ "version": "4.14.1",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -42,8 +42,8 @@
42
42
  "@protobuf-ts/grpcweb-transport": "^2.11.1",
43
43
  "@protobuf-ts/runtime": "^2.11.1",
44
44
  "@protobuf-ts/runtime-rpc": "^2.11.1",
45
- "@structured-id/ids": "^0.3.0",
46
- "@structured-id/proto": "^0.11.0",
45
+ "@structured-id/ids": "^0.4.0",
46
+ "@structured-id/proto": "^0.12.0",
47
47
  "@structured-world/structured-email-address-ts": "^0.0.22",
48
48
  "@structured-world/structured-public-domains": "0.0.x",
49
49
  "libphonenumber-js": "^1.13.15"
@@ -86,7 +86,7 @@
86
86
 
87
87
  <script setup lang="ts">
88
88
  import { reactive, computed, watch } from "vue";
89
- import type { Profile } from "@structured-id/proto/sid/v1/identity/identity";
89
+ import type { Profile } from "../composables/useIdentityApi";
90
90
 
91
91
  const props = withDefaults(
92
92
  defineProps<{
@@ -52,7 +52,7 @@
52
52
  </div>
53
53
 
54
54
  <q-list v-else-if="credentials.length > 0" separator>
55
- <q-item v-for="cred in credentials" :key="cred.id">
55
+ <q-item v-for="cred in credentials" :key="cred.id.toString()">
56
56
  <q-item-section avatar>
57
57
  <q-icon :name="credIcon(cred.type)" :color="credColor(cred.type)" />
58
58
  </q-item-section>
@@ -96,11 +96,9 @@
96
96
  </template>
97
97
 
98
98
  <script setup lang="ts">
99
- import {
100
- type Credential,
101
- CredentialType,
102
- } from "@structured-id/proto/sid/v1/identity/identity";
99
+ import { CredentialType } from "@structured-id/proto/sid/v1/identity/identity";
103
100
  import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
101
+ import type { Credential } from "../composables/useIdentityApi";
104
102
  import type { FactorCategory } from "../composables/useSecurityLevel";
105
103
 
106
104
  defineProps<{
@@ -9,7 +9,7 @@
9
9
  </div>
10
10
 
11
11
  <q-list v-else-if="sessions.length > 0" separator>
12
- <q-item v-for="session in sessions" :key="session.id">
12
+ <q-item v-for="session in sessions" :key="session.id.toString()">
13
13
  <q-item-section avatar>
14
14
  <q-icon :name="deviceIcon(session.userAgent)" size="sm" />
15
15
  </q-item-section>
@@ -37,7 +37,7 @@
37
37
  dense
38
38
  color="negative"
39
39
  icon="sym_o_logout"
40
- :loading="revoking === session.id"
40
+ :loading="revoking?.equals(session.id) ?? false"
41
41
  @click="$emit('revoke', session.id)"
42
42
  />
43
43
  </q-item-section>
@@ -49,23 +49,24 @@
49
49
  </template>
50
50
 
51
51
  <script setup lang="ts">
52
- import type { Session } from "@structured-id/proto/sid/v1/identity/identity";
52
+ import type { SessionId } from "@structured-id/ids";
53
53
  import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
54
+ import type { Session } from "../composables/useIdentityApi";
54
55
 
55
- defineProps<{
56
+ const props = defineProps<{
56
57
  sessions: Session[];
57
58
  loading: boolean;
58
59
  error: string | null;
59
- revoking: string | null;
60
- currentSessionId?: string;
60
+ revoking: SessionId | null;
61
+ currentSessionId?: SessionId;
61
62
  }>();
62
63
 
63
64
  defineEmits<{
64
- revoke: [sessionId: string];
65
+ revoke: [sessionId: SessionId];
65
66
  }>();
66
67
 
67
68
  function isCurrentSession(session: Session): boolean {
68
- return false; // Overridden via currentSessionId prop comparison
69
+ return props.currentSessionId?.equals(session.id) ?? false;
69
70
  }
70
71
 
71
72
  function deviceIcon(userAgent: string | undefined): string {
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import {
3
+ CredentialId,
4
+ DeviceId,
5
+ IdError,
6
+ MissingIdError,
7
+ PrincipalId,
8
+ ProfileEmailId,
9
+ ProfileId,
10
+ SessionId,
11
+ } from "@structured-id/ids";
12
+ import {
13
+ fetchCredentials,
14
+ fetchCurrentProfile,
15
+ fetchPrincipals,
16
+ fetchSessions,
17
+ removePrincipal,
18
+ revokeCredential,
19
+ revokeSession,
20
+ updateCredentialLabel,
21
+ updateProfile,
22
+ } from "./useIdentityApi";
23
+
24
+ // Every IdentityService method the tests call, keyed by its client method name.
25
+ const rpc: Record<string, ReturnType<typeof vi.fn>> = {};
26
+
27
+ vi.mock("../../index", () => ({
28
+ getAccountTransport: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("@structured-id/proto/sid/v1/identity/identity.client", () => ({
32
+ // Called with `new`: a constructor mock must be a `function`, not an arrow.
33
+ IdentityServiceClient: vi.fn().mockImplementation(function () {
34
+ return new Proxy(
35
+ {},
36
+ { get: (_target, name: string) => (rpc[name] ??= vi.fn()) },
37
+ );
38
+ }),
39
+ }));
40
+
41
+ vi.mock("../auth", () => ({
42
+ authMeta: () => ({}),
43
+ }));
44
+
45
+ const PROFILE = ProfileId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d20");
46
+ const SESSION = SessionId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d21");
47
+ const DEVICE = DeviceId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d22");
48
+ const CRED = CredentialId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d23");
49
+ const PRINCIPAL = PrincipalId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d24");
50
+ const EMAIL = ProfileEmailId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d25");
51
+ const MALFORMED = { value: new Uint8Array(15) };
52
+
53
+ /** The RPC `name` answers `response` once. */
54
+ function answers(name: string, response: object) {
55
+ rpc[name] ??= vi.fn();
56
+ rpc[name].mockResolvedValueOnce({ response });
57
+ }
58
+
59
+ /** The request the RPC `name` was last called with. */
60
+ function sent(name: string): unknown {
61
+ return rpc[name].mock.calls.at(-1)?.[0];
62
+ }
63
+
64
+ beforeEach(() => {
65
+ for (const fn of Object.values(rpc)) fn.mockReset();
66
+ });
67
+
68
+ describe("own listings", () => {
69
+ // Regression: the page sent the BFF subject as its ProfileId. A
70
+ // self-service listing names no Profile; the server resolves the
71
+ // caller's own from its session.
72
+ it("names no Profile", async () => {
73
+ answers("listSessions", { sessions: [] });
74
+ answers("listCredentials", { credentials: [] });
75
+ answers("listPrincipals", { principals: [] });
76
+ await fetchSessions();
77
+ await fetchCredentials();
78
+ await fetchPrincipals();
79
+ expect(sent("listSessions")).toEqual({});
80
+ expect(sent("listCredentials")).toEqual({});
81
+ expect(sent("listPrincipals")).toEqual({});
82
+ });
83
+
84
+ it("reads each listed reference", async () => {
85
+ answers("listSessions", {
86
+ sessions: [
87
+ { id: SESSION.toWire(), deviceId: DEVICE.toWire() },
88
+ { id: SESSION.toWire() },
89
+ ],
90
+ });
91
+ const [onDevice, without] = await fetchSessions();
92
+ expect(onDevice.id.equals(SESSION)).toBe(true);
93
+ expect(onDevice.deviceId?.equals(DEVICE)).toBe(true);
94
+ expect(without.deviceId).toBeUndefined();
95
+
96
+ answers("listPrincipals", {
97
+ principals: [{ id: PRINCIPAL.toWire(), sourceEmailId: EMAIL.toWire() }],
98
+ });
99
+ const [principal] = await fetchPrincipals();
100
+ expect(principal.id.equals(PRINCIPAL)).toBe(true);
101
+ expect(principal.sourceEmailId?.equals(EMAIL)).toBe(true);
102
+ });
103
+
104
+ // An answer that owes a reference and carries a missing or malformed one
105
+ // fails here, not on a page that would act on it.
106
+ it("refuses a missing or malformed reference", async () => {
107
+ answers("listCredentials", { credentials: [{}] });
108
+ await expect(fetchCredentials()).rejects.toBeInstanceOf(MissingIdError);
109
+ answers("listSessions", { sessions: [{ id: MALFORMED }] });
110
+ await expect(fetchSessions()).rejects.toBeInstanceOf(IdError);
111
+ answers("listPrincipals", {
112
+ principals: [{ id: PRINCIPAL.toWire(), sourceEmailId: MALFORMED }],
113
+ });
114
+ await expect(fetchPrincipals()).rejects.toBeInstanceOf(IdError);
115
+ });
116
+ });
117
+
118
+ describe("profile", () => {
119
+ it("updates the profile it fetched, by its typed id", async () => {
120
+ answers("getCurrentProfile", { profile: { id: PROFILE.toWire() } });
121
+ const own = await fetchCurrentProfile();
122
+ expect(own.id.equals(PROFILE)).toBe(true);
123
+
124
+ answers("updateProfile", { profile: { id: PROFILE.toWire() } });
125
+ await updateProfile(own.id, { givenName: "Alicia" });
126
+ expect(sent("updateProfile")).toMatchObject({
127
+ id: { value: PROFILE.toBytes() },
128
+ givenName: "Alicia",
129
+ });
130
+ });
131
+
132
+ it("refuses an answer without its profile", async () => {
133
+ answers("getCurrentProfile", {});
134
+ await expect(fetchCurrentProfile()).rejects.toBeInstanceOf(TypeError);
135
+ });
136
+ });
137
+
138
+ describe("record actions", () => {
139
+ it("name each record by its binary identifier", async () => {
140
+ answers("revokeSession", {});
141
+ await revokeSession(SESSION);
142
+ expect(sent("revokeSession")).toEqual({
143
+ sessionId: { value: SESSION.toBytes() },
144
+ });
145
+
146
+ answers("revokeCredential", {});
147
+ await revokeCredential(CRED);
148
+ expect(sent("revokeCredential")).toEqual({
149
+ credentialId: { value: CRED.toBytes() },
150
+ });
151
+
152
+ answers("updateCredential", { credential: { id: CRED.toWire() } });
153
+ const renamed = await updateCredentialLabel(CRED, "laptop");
154
+ expect(sent("updateCredential")).toEqual({
155
+ credentialId: { value: CRED.toBytes() },
156
+ label: "laptop",
157
+ });
158
+ expect(renamed.id.equals(CRED)).toBe(true);
159
+
160
+ answers("removePrincipal", {});
161
+ await removePrincipal(PRINCIPAL);
162
+ expect(sent("removePrincipal")).toEqual({
163
+ principalId: { value: PRINCIPAL.toBytes() },
164
+ });
165
+ });
166
+ });
@@ -4,15 +4,28 @@
4
4
  * The account API through the BFF (`getAccountTransport`), which adds the
5
5
  * session's Bearer token. Shared between profile-ui-ce, profile-ui-ee, and
6
6
  * frontend.
7
+ *
8
+ * Self-service listings name no Profile: the server resolves the caller's
9
+ * own from its session, so the page never needs its own ProfileId.
7
10
  */
8
11
  import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
12
+ import {
13
+ CredentialId,
14
+ DeviceId,
15
+ PrincipalId,
16
+ ProfileEmailId,
17
+ ProfileId,
18
+ SessionId,
19
+ optional,
20
+ required,
21
+ } from "@structured-id/ids";
9
22
  import { getAccountTransport } from "../../index";
10
23
  import { IdentityServiceClient } from "@structured-id/proto/sid/v1/identity/identity.client";
11
24
  import {
12
- type Profile,
13
- type Session,
14
- type Credential,
15
- type Principal,
25
+ type Profile as WireProfile,
26
+ type Session as WireSession,
27
+ type Credential as WireCredential,
28
+ type Principal as WirePrincipal,
16
29
  ProfileStatus,
17
30
  PrincipalType,
18
31
  CredentialType,
@@ -20,6 +33,7 @@ import {
20
33
  } from "@structured-id/proto/sid/v1/identity/identity";
21
34
  import { Timestamp } from "@structured-id/proto/google/protobuf/timestamp";
22
35
  import { authMeta } from "../auth";
36
+ import type { WithId } from "./useAuthApi";
23
37
 
24
38
  function client(transport?: RpcTransport): IdentityServiceClient {
25
39
  return new IdentityServiceClient(transport ?? getAccountTransport());
@@ -27,22 +41,36 @@ function client(transport?: RpcTransport): IdentityServiceClient {
27
41
 
28
42
  // ── Profile ──
29
43
 
30
- export type { Profile, Principal };
44
+ /** A Profile as answered, its id read into a typed identifier. */
45
+ export type Profile = WithId<WireProfile, "id", ProfileId>;
46
+
47
+ function profile(wire: WireProfile | undefined): Profile {
48
+ if (!wire) throw new TypeError("the answer carries no profile");
49
+ return { ...wire, id: required(ProfileId, wire.id) };
50
+ }
31
51
 
32
- export async function fetchCurrentProfile(): Promise<Profile> {
33
- const { response } = await client().getCurrentProfile(
52
+ /**
53
+ * The signed-in user's own profile. `transport`: the sign-in surface's
54
+ * transport when a sign-in step reads it; the account API otherwise.
55
+ */
56
+ export async function fetchCurrentProfile(
57
+ transport?: RpcTransport,
58
+ ): Promise<Profile> {
59
+ const { response } = await client(transport).getCurrentProfile(
34
60
  {},
35
61
  { meta: authMeta() },
36
62
  );
37
- return response.profile!;
63
+ return profile(response.profile);
38
64
  }
39
65
 
40
66
  /**
67
+ * Update the profile `id` (the one {@link fetchCurrentProfile} answered).
68
+ *
41
69
  * `transport`: the sign-in surface's transport when a sign-in step (a
42
70
  * required profile update) makes the call; the account API otherwise.
43
71
  */
44
72
  export async function updateProfile(
45
- id: string,
73
+ id: ProfileId,
46
74
  update: {
47
75
  givenName?: string;
48
76
  familyName?: string;
@@ -53,7 +81,7 @@ export async function updateProfile(
53
81
  ): Promise<Profile> {
54
82
  const { response } = await client(transport).updateProfile(
55
83
  {
56
- id,
84
+ id: id.toWire(),
57
85
  givenName: update.givenName,
58
86
  familyName: update.familyName,
59
87
  middleName: update.middleName,
@@ -61,66 +89,93 @@ export async function updateProfile(
61
89
  },
62
90
  { meta: authMeta() },
63
91
  );
64
- return response.profile!;
92
+ return profile(response.profile);
65
93
  }
66
94
 
67
95
  // ── Sessions ──
68
96
 
69
- export type { Session };
97
+ /** A session as answered, with its own and its device's typed ids. */
98
+ export type Session = WithId<
99
+ WithId<WireSession, "id", SessionId>,
100
+ "deviceId",
101
+ DeviceId | undefined
102
+ >;
103
+
104
+ /** The caller's own sessions. */
105
+ export async function fetchSessions(): Promise<Session[]> {
106
+ const { response } = await client().listSessions({}, { meta: authMeta() });
107
+ return response.sessions.map((s) => ({
108
+ ...s,
109
+ id: required(SessionId, s.id),
110
+ deviceId: optional(DeviceId, s.deviceId),
111
+ }));
112
+ }
70
113
 
71
- export async function fetchSessions(profileId: string): Promise<Session[]> {
72
- const { response } = await client().listSessions(
73
- { profileId },
114
+ export async function revokeSession(id: SessionId): Promise<void> {
115
+ await client().revokeSession(
116
+ { sessionId: id.toWire() },
74
117
  { meta: authMeta() },
75
118
  );
76
- return response.sessions;
77
- }
78
-
79
- export async function revokeSession(sessionId: string): Promise<void> {
80
- await client().revokeSession({ sessionId }, { meta: authMeta() });
81
119
  }
82
120
 
83
121
  // ── Credentials ──
84
122
 
85
- export type { Credential };
123
+ /** A credential as answered, its id read into a typed identifier. */
124
+ export type Credential = WithId<WireCredential, "id", CredentialId>;
86
125
 
87
- export async function fetchCredentials(
88
- profileId: string,
89
- ): Promise<Credential[]> {
90
- const { response } = await client().listCredentials(
91
- { profileId },
92
- { meta: authMeta() },
93
- );
94
- return response.credentials;
126
+ function credential(wire: WireCredential | undefined): Credential {
127
+ if (!wire) throw new TypeError("the answer carries no credential");
128
+ return { ...wire, id: required(CredentialId, wire.id) };
95
129
  }
96
130
 
97
- export async function revokeCredential(credentialId: string): Promise<void> {
98
- await client().revokeCredential({ credentialId }, { meta: authMeta() });
131
+ /** The caller's own credentials. */
132
+ export async function fetchCredentials(): Promise<Credential[]> {
133
+ const { response } = await client().listCredentials({}, { meta: authMeta() });
134
+ return response.credentials.map(credential);
135
+ }
136
+
137
+ export async function revokeCredential(id: CredentialId): Promise<void> {
138
+ await client().revokeCredential(
139
+ { credentialId: id.toWire() },
140
+ { meta: authMeta() },
141
+ );
99
142
  }
100
143
 
101
144
  export async function updateCredentialLabel(
102
- credentialId: string,
145
+ id: CredentialId,
103
146
  label: string,
104
147
  ): Promise<Credential> {
105
148
  const { response } = await client().updateCredential(
106
- { credentialId, label },
149
+ { credentialId: id.toWire(), label },
107
150
  { meta: authMeta() },
108
151
  );
109
- return response.credential!;
152
+ return credential(response.credential);
110
153
  }
111
154
 
112
155
  // ── Principals ──
113
156
 
114
- export async function fetchPrincipals(profileId: string): Promise<Principal[]> {
115
- const { response } = await client().listPrincipals(
116
- { profileId },
117
- { meta: authMeta() },
118
- );
119
- return response.principals;
157
+ /** A login handle as answered, with its own and its contact's typed ids. */
158
+ export type Principal = WithId<
159
+ WithId<WirePrincipal, "id", PrincipalId>,
160
+ "sourceEmailId",
161
+ ProfileEmailId | undefined
162
+ >;
163
+
164
+ /** The caller's own login handles. */
165
+ export async function fetchPrincipals(): Promise<Principal[]> {
166
+ const { response } = await client().listPrincipals({}, { meta: authMeta() });
167
+ return response.principals.map((p) => ({
168
+ ...p,
169
+ id: required(PrincipalId, p.id),
170
+ sourceEmailId: optional(ProfileEmailId, p.sourceEmailId),
171
+ }));
120
172
  }
121
173
 
122
- export async function removePrincipal(principalId: string): Promise<void> {
123
- await client().removePrincipal({ principalId }, { meta: authMeta() });
174
+ export async function removePrincipal(id: PrincipalId): Promise<void> {
175
+ await client().removePrincipal(
176
+ { principalId: id.toWire() },
177
+ { meta: authMeta() },
178
+ );
124
179
  }
125
180
 
126
181
  // Re-export enums and Timestamp for convenience
@@ -101,7 +101,7 @@ describe("useLoginSuccess (BFF cookie mode)", () => {
101
101
 
102
102
  it("calls checkBffSession and populates auth store on success", async () => {
103
103
  mockCheckBffSession.mockResolvedValue({
104
- sub: "profile-uuid-1",
104
+ sub: "subject-1",
105
105
  name: "Alice Smith",
106
106
  email: "alice@example.com",
107
107
  preferred_username: "alice",
@@ -114,7 +114,6 @@ describe("useLoginSuccess (BFF cookie mode)", () => {
114
114
 
115
115
  expect(mockCheckBffSession).toHaveBeenCalled();
116
116
  expect(auth.isAuthenticated).toBe(true);
117
- expect(auth.profileId).toBe("profile-uuid-1");
118
117
  expect(auth.displayName).toBe("Alice Smith");
119
118
  expect(auth.email).toBe("alice@example.com");
120
119
  });
@@ -1,5 +1,10 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { ref } from "vue";
2
+ import { ref, shallowRef } from "vue";
3
+ import { CredentialId, PrincipalId } from "@structured-id/ids";
4
+ import type {
5
+ Credential as IdentityCredential,
6
+ Principal as IdentityPrincipal,
7
+ } from "./useIdentityApi";
3
8
  import {
4
9
  useSecurityLevel,
5
10
  SecurityLevel,
@@ -20,8 +25,6 @@ function makeCred(
20
25
  opts?: { attachment?: AuthenticatorAttachment; backupState?: boolean },
21
26
  ): Credential {
22
27
  const cred: Credential = {
23
- id: "cred-1",
24
- profileId: "",
25
28
  type,
26
29
  data: new Uint8Array(),
27
30
  info: { oneofKind: undefined },
@@ -44,7 +47,6 @@ function makeCred(
44
47
 
45
48
  function makePrincipal(type: PrincipalType, verified: boolean): Principal {
46
49
  return {
47
- id: "p-1",
48
50
  type,
49
51
  value: "test",
50
52
  verified,
@@ -56,6 +58,30 @@ function makePrincipal(type: PrincipalType, verified: boolean): Principal {
56
58
  // ── Security Level ──
57
59
 
58
60
  describe("useSecurityLevel", () => {
61
+ // Regression: the assessment took the wire types, so the typed lists the
62
+ // identity composable answers (held in shallow refs) did not fit it.
63
+ it("assesses the identity composable's typed lists", () => {
64
+ const creds = shallowRef<IdentityCredential[]>([
65
+ {
66
+ ...makeCred(CredentialType.OPAQUE),
67
+ id: CredentialId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d50"),
68
+ },
69
+ ]);
70
+ const principals = shallowRef<IdentityPrincipal[]>([
71
+ {
72
+ ...makePrincipal(PrincipalType.EMAIL, true),
73
+ id: PrincipalId.parse("0192f3a4-7c1e-7b2a-9d4e-3f5a6b7c8d51"),
74
+ sourceEmailId: undefined,
75
+ },
76
+ ]);
77
+ const { factorCount, recoveryMethods } = useSecurityLevel(
78
+ creds,
79
+ principals,
80
+ );
81
+ expect(factorCount.value).toBe(1);
82
+ expect(recoveryMethods.value).toContain("recovery_email");
83
+ });
84
+
59
85
  it("MINIMAL with no credentials", () => {
60
86
  const { securityLevel } = useSecurityLevel(ref([]), ref([]));
61
87
  expect(securityLevel.value).toBe(SecurityLevel.MINIMAL);
@@ -13,6 +13,15 @@ import {
13
13
  PrincipalType,
14
14
  } from "@structured-id/proto/sid/v1/identity/identity";
15
15
 
16
+ /** What the assessment reads of a credential: its kind and authenticator. */
17
+ export type CredentialFacts = Pick<Credential, "type" | "info">;
18
+
19
+ /** What the assessment reads of a login handle: its kind and proof. */
20
+ export type PrincipalFacts = Pick<
21
+ Principal,
22
+ "type" | "verified" | "needsAddressConfirmation"
23
+ >;
24
+
16
25
  // ── Security Level ──
17
26
 
18
27
  export enum SecurityLevel {
@@ -66,7 +75,7 @@ const RECOVERABILITY_COLORS: Record<RecoverabilityLevel, string> = {
66
75
 
67
76
  export type FactorCategory = "knowledge" | "possession" | "inherence";
68
77
 
69
- function getCategory(cred: Credential): FactorCategory | null {
78
+ function getCategory(cred: CredentialFacts): FactorCategory | null {
70
79
  switch (cred.type) {
71
80
  case CredentialType.OPAQUE:
72
81
  case CredentialType.LEGACY_HASH:
@@ -89,7 +98,7 @@ function getCategory(cred: Credential): FactorCategory | null {
89
98
  }
90
99
  }
91
100
 
92
- function isHardwareKey(cred: Credential): boolean {
101
+ function isHardwareKey(cred: CredentialFacts): boolean {
93
102
  return (
94
103
  cred.type === CredentialType.WEBAUTHN &&
95
104
  cred.info.oneofKind === "webauthnInfo" &&
@@ -113,8 +122,8 @@ export interface ActionNudge {
113
122
  // ── Composable ──
114
123
 
115
124
  export function useSecurityLevel(
116
- credentials: Ref<Credential[]>,
117
- identifiers: Ref<Principal[]>,
125
+ credentials: Readonly<Ref<readonly CredentialFacts[]>>,
126
+ identifiers: Readonly<Ref<readonly PrincipalFacts[]>>,
118
127
  ) {
119
128
  const authFactors = computed(() =>
120
129
  credentials.value.filter((c) => c.type !== CredentialType.RECOVERY),
@@ -11,7 +11,6 @@ describe("useAuthStore (BFF cookie session)", () => {
11
11
  const store = useAuthStore();
12
12
  expect(store.isAuthenticated).toBe(false);
13
13
  expect(store.sessionValid).toBe(false);
14
- expect(store.profileId).toBeNull();
15
14
  expect(store.displayName).toBeNull();
16
15
  expect(store.email).toBeNull();
17
16
  });
@@ -19,7 +18,7 @@ describe("useAuthStore (BFF cookie session)", () => {
19
18
  it("setBffSession sets sessionValid and profile data", () => {
20
19
  const store = useAuthStore();
21
20
  store.setBffSession({
22
- sub: "profile-uuid-123",
21
+ sub: "subject-123",
23
22
  email: "alice@example.com",
24
23
  name: "Alice Smith",
25
24
  preferred_username: "alice",
@@ -28,18 +27,20 @@ describe("useAuthStore (BFF cookie session)", () => {
28
27
 
29
28
  expect(store.isAuthenticated).toBe(true);
30
29
  expect(store.sessionValid).toBe(true);
31
- expect(store.profileId).toBe("profile-uuid-123");
32
30
  expect(store.displayName).toBe("Alice Smith");
33
31
  expect(store.email).toBe("alice@example.com");
34
32
  expect(store.preferredUsername).toBe("alice");
35
33
  });
36
34
 
37
- it("setBffSession handles null/missing optional fields", () => {
35
+ // Regression: the userinfo `sub` was kept as the user's ProfileId, though
36
+ // it is the subject this application knows the user by. The store keeps
37
+ // no Profile id at all.
38
+ it("never reads the subject as a Profile id", () => {
38
39
  const store = useAuthStore();
39
- store.setBffSession({ sub: "uuid-456" });
40
+ store.setBffSession({ sub: "subject-456" });
40
41
 
41
42
  expect(store.isAuthenticated).toBe(true);
42
- expect(store.profileId).toBe("uuid-456");
43
+ expect("profileId" in store).toBe(false);
43
44
  expect(store.displayName).toBeNull();
44
45
  expect(store.email).toBeNull();
45
46
  expect(store.preferredUsername).toBeNull();
@@ -47,15 +48,13 @@ describe("useAuthStore (BFF cookie session)", () => {
47
48
 
48
49
  it("setProfile updates display data", () => {
49
50
  const store = useAuthStore();
50
- store.setBffSession({ sub: "uuid-1" });
51
+ store.setBffSession({ sub: "subject-1" });
51
52
 
52
53
  store.setProfile({
53
- id: "uuid-1",
54
54
  displayName: "Bob Jones",
55
55
  email: "bob@example.com",
56
56
  });
57
57
 
58
- expect(store.profileId).toBe("uuid-1");
59
58
  expect(store.displayName).toBe("Bob Jones");
60
59
  expect(store.email).toBe("bob@example.com");
61
60
  });
@@ -63,7 +62,7 @@ describe("useAuthStore (BFF cookie session)", () => {
63
62
  it("logout clears all state", () => {
64
63
  const store = useAuthStore();
65
64
  store.setBffSession({
66
- sub: "uuid-1",
65
+ sub: "subject-1",
67
66
  name: "Alice",
68
67
  email: "alice@example.com",
69
68
  preferred_username: "alice",
@@ -73,7 +72,6 @@ describe("useAuthStore (BFF cookie session)", () => {
73
72
 
74
73
  expect(store.isAuthenticated).toBe(false);
75
74
  expect(store.sessionValid).toBe(false);
76
- expect(store.profileId).toBeNull();
77
75
  expect(store.displayName).toBeNull();
78
76
  expect(store.email).toBeNull();
79
77
  expect(store.preferredUsername).toBeNull();
@@ -24,9 +24,11 @@ export const useAuthStore = defineStore("auth", () => {
24
24
  const sessionId = ref<string | null>(null);
25
25
 
26
26
  // ── Profile info (populated from BFF /auth/userinfo) ──────────────
27
+ //
28
+ // No Profile id: the userinfo `sub` is the subject this application
29
+ // knows the user by, not a ProfileId, and self-service calls name no
30
+ // Profile at all.
27
31
 
28
- /** Profile ID of the authenticated user (= sub from BFF userinfo). */
29
- const profileId = ref<string | null>(null);
30
32
  /** Display name. */
31
33
  const displayName = ref<string | null>(null);
32
34
  /** Email address. */
@@ -46,7 +48,6 @@ export const useAuthStore = defineStore("auth", () => {
46
48
  */
47
49
  function setBffSession(info: BffUserInfo): void {
48
50
  sessionValid.value = true;
49
- profileId.value = info.sub;
50
51
  displayName.value = info.name ?? null;
51
52
  email.value = info.email ?? null;
52
53
  preferredUsername.value = info.preferred_username ?? null;
@@ -56,11 +57,9 @@ export const useAuthStore = defineStore("auth", () => {
56
57
  * Update profile display data (e.g., after profile edit).
57
58
  */
58
59
  function setProfile(profile: {
59
- id: string;
60
60
  displayName?: string | null;
61
61
  email?: string | null;
62
62
  }): void {
63
- profileId.value = profile.id;
64
63
  displayName.value = profile.displayName ?? null;
65
64
  email.value = profile.email ?? null;
66
65
  }
@@ -71,7 +70,6 @@ export const useAuthStore = defineStore("auth", () => {
71
70
  function logout(): void {
72
71
  sessionValid.value = false;
73
72
  sessionId.value = null;
74
- profileId.value = null;
75
73
  displayName.value = null;
76
74
  email.value = null;
77
75
  preferredUsername.value = null;
@@ -80,7 +78,6 @@ export const useAuthStore = defineStore("auth", () => {
80
78
  return {
81
79
  sessionValid,
82
80
  sessionId,
83
- profileId,
84
81
  displayName,
85
82
  email,
86
83
  preferredUsername,
package/src/types/auth.ts CHANGED
@@ -4,8 +4,6 @@ export interface AuthState {
4
4
  sessionValid: boolean;
5
5
  /** Server-side session identifier (for step-up, session management). */
6
6
  sessionId: string | null;
7
- /** Profile ID of the authenticated user (UUID). */
8
- profileId: string | null;
9
7
  /** Display name of the authenticated user. */
10
8
  displayName: string | null;
11
9
  /** Email address (may be null if not in session claims). */