@structured-id/ui-core 4.13.0 → 4.14.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 +3 -3
- package/src/profile/components/SessionList.vue +9 -8
- package/src/profile/composables/useIdentityApi.test.ts +166 -0
- package/src/profile/composables/useIdentityApi.ts +97 -42
- package/src/profile/composables/useLoginSuccess.test.ts +1 -2
- package/src/profile/composables/useSecurityLevel.test.ts +0 -2
- package/src/stores/auth.test.ts +9 -11
- package/src/stores/auth.ts +4 -7
- package/src/types/auth.ts +0 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structured-id/ui-core",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
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.
|
|
46
|
-
"@structured-id/proto": "^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"
|
|
@@ -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
|
|
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 {
|
|
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:
|
|
60
|
-
currentSessionId?:
|
|
60
|
+
revoking: SessionId | null;
|
|
61
|
+
currentSessionId?: SessionId;
|
|
61
62
|
}>();
|
|
62
63
|
|
|
63
64
|
defineEmits<{
|
|
64
|
-
revoke: [sessionId:
|
|
65
|
+
revoke: [sessionId: SessionId];
|
|
65
66
|
}>();
|
|
66
67
|
|
|
67
68
|
function isCurrentSession(session: Session): boolean {
|
|
68
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
{
|
|
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
|
-
|
|
123
|
+
/** A credential as answered, its id read into a typed identifier. */
|
|
124
|
+
export type Credential = WithId<WireCredential, "id", CredentialId>;
|
|
86
125
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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(
|
|
123
|
-
await client().removePrincipal(
|
|
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: "
|
|
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
|
});
|
package/src/stores/auth.test.ts
CHANGED
|
@@ -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: "
|
|
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
|
-
|
|
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: "
|
|
40
|
+
store.setBffSession({ sub: "subject-456" });
|
|
40
41
|
|
|
41
42
|
expect(store.isAuthenticated).toBe(true);
|
|
42
|
-
expect(store
|
|
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: "
|
|
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: "
|
|
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();
|
package/src/stores/auth.ts
CHANGED
|
@@ -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). */
|