@structured-id/ui-core 0.1.5
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/LICENSE +661 -0
- package/README.md +47 -0
- package/dist/browser-vp/index.cjs +349 -0
- package/dist/browser-vp/index.d.cts +287 -0
- package/dist/browser-vp/index.d.ts +287 -0
- package/dist/browser-vp/index.js +349 -0
- package/dist/chunk-C3Q766CY.cjs +125 -0
- package/dist/chunk-J5YXRXB5.js +125 -0
- package/dist/index.cjs +19360 -0
- package/dist/index.d.cts +9536 -0
- package/dist/index.d.ts +9536 -0
- package/dist/index.js +19360 -0
- package/dist/storage-4CB2JTLW.cjs +22 -0
- package/dist/storage-THRWREZP.js +22 -0
- package/package.json +93 -0
- package/src/browser-vp/crypto.ts +254 -0
- package/src/browser-vp/index.test.ts +10 -0
- package/src/browser-vp/index.ts +118 -0
- package/src/browser-vp/prf.ts +181 -0
- package/src/browser-vp/storage.test.ts +9 -0
- package/src/browser-vp/storage.ts +165 -0
- package/src/browser-vp/types.ts +97 -0
- package/src/browser-vp/vp.test.ts +43 -0
- package/src/browser-vp/vp.ts +113 -0
- package/src/components/SidAvatar.vue +106 -0
- package/src/components/SidConfirmDialog.vue +125 -0
- package/src/components/SidStatusBadge.vue +71 -0
- package/src/components/index.ts +5 -0
- package/src/composables/index.ts +10 -0
- package/src/composables/useAuth.ts +40 -0
- package/src/composables/useBffAuth.ts +147 -0
- package/src/composables/useGrpc.test.ts +196 -0
- package/src/composables/useGrpc.ts +120 -0
- package/src/composables/useSession.ts +80 -0
- package/src/generated/google/api/http.ts +687 -0
- package/src/generated/google/protobuf/descriptor.ts +4865 -0
- package/src/generated/google/protobuf/duration.ts +230 -0
- package/src/generated/google/protobuf/empty.ts +94 -0
- package/src/generated/google/protobuf/struct.ts +484 -0
- package/src/generated/google/protobuf/timestamp.ts +287 -0
- package/src/generated/sid/v1/account/account.client.ts +527 -0
- package/src/generated/sid/v1/account/account.ts +3261 -0
- package/src/generated/sid/v1/admin/admin.client.ts +136 -0
- package/src/generated/sid/v1/admin/admin.ts +846 -0
- package/src/generated/sid/v1/admin/admin_roles.client.ts +181 -0
- package/src/generated/sid/v1/admin/admin_roles.ts +1606 -0
- package/src/generated/sid/v1/admin/analytics.client.ts +106 -0
- package/src/generated/sid/v1/admin/analytics.ts +1017 -0
- package/src/generated/sid/v1/admin/branding.client.ts +166 -0
- package/src/generated/sid/v1/admin/branding.ts +1906 -0
- package/src/generated/sid/v1/admin/enrollment.client.ts +165 -0
- package/src/generated/sid/v1/admin/enrollment.ts +1704 -0
- package/src/generated/sid/v1/admin/events.client.ts +195 -0
- package/src/generated/sid/v1/admin/events.ts +1900 -0
- package/src/generated/sid/v1/admin/flow.client.ts +183 -0
- package/src/generated/sid/v1/admin/flow.ts +1552 -0
- package/src/generated/sid/v1/admin/keys.client.ts +107 -0
- package/src/generated/sid/v1/admin/keys.ts +931 -0
- package/src/generated/sid/v1/admin/login_experience.client.ts +160 -0
- package/src/generated/sid/v1/admin/login_experience.ts +1061 -0
- package/src/generated/sid/v1/admin/migration.client.ts +127 -0
- package/src/generated/sid/v1/admin/migration.ts +1948 -0
- package/src/generated/sid/v1/admin/notifications.client.ts +128 -0
- package/src/generated/sid/v1/admin/notifications.ts +1332 -0
- package/src/generated/sid/v1/admin/operations.client.ts +241 -0
- package/src/generated/sid/v1/admin/operations.ts +2648 -0
- package/src/generated/sid/v1/admin/organizations.client.ts +57 -0
- package/src/generated/sid/v1/admin/organizations.ts +349 -0
- package/src/generated/sid/v1/admin/scopes.client.ts +171 -0
- package/src/generated/sid/v1/admin/scopes.ts +1324 -0
- package/src/generated/sid/v1/admin/security.client.ts +412 -0
- package/src/generated/sid/v1/admin/security.ts +4086 -0
- package/src/generated/sid/v1/admin/sessions.client.ts +114 -0
- package/src/generated/sid/v1/admin/sessions.ts +923 -0
- package/src/generated/sid/v1/admin/settings.client.ts +340 -0
- package/src/generated/sid/v1/admin/settings.ts +2395 -0
- package/src/generated/sid/v1/admin/user_profile.client.ts +57 -0
- package/src/generated/sid/v1/admin/user_profile.ts +730 -0
- package/src/generated/sid/v1/admin/webhooks.client.ts +269 -0
- package/src/generated/sid/v1/admin/webhooks.ts +2804 -0
- package/src/generated/sid/v1/attestation/attestation.client.ts +115 -0
- package/src/generated/sid/v1/attestation/attestation.ts +813 -0
- package/src/generated/sid/v1/authn/auth.client.ts +900 -0
- package/src/generated/sid/v1/authn/auth.ts +9287 -0
- package/src/generated/sid/v1/authn/flows.client.ts +413 -0
- package/src/generated/sid/v1/authn/flows.ts +3217 -0
- package/src/generated/sid/v1/authz/authz.client.ts +459 -0
- package/src/generated/sid/v1/authz/authz.ts +5343 -0
- package/src/generated/sid/v1/authz/governance.client.ts +102 -0
- package/src/generated/sid/v1/authz/governance.ts +818 -0
- package/src/generated/sid/v1/common/errors.ts +184 -0
- package/src/generated/sid/v1/common/pagination.ts +152 -0
- package/src/generated/sid/v1/common/transport.ts +227 -0
- package/src/generated/sid/v1/common/types.ts +151 -0
- package/src/generated/sid/v1/events/event_stream.client.ts +67 -0
- package/src/generated/sid/v1/events/event_stream.ts +374 -0
- package/src/generated/sid/v1/federation/upstream.client.ts +142 -0
- package/src/generated/sid/v1/federation/upstream.ts +1736 -0
- package/src/generated/sid/v1/identity/identity.client.ts +467 -0
- package/src/generated/sid/v1/identity/identity.ts +5613 -0
- package/src/generated/sid/v1/machine/machine_user.client.ts +219 -0
- package/src/generated/sid/v1/machine/machine_user.ts +2400 -0
- package/src/generated/sid/v1/machine/pat.client.ts +147 -0
- package/src/generated/sid/v1/machine/pat.ts +1268 -0
- package/src/generated/sid/v1/projects/projects.client.ts +286 -0
- package/src/generated/sid/v1/projects/projects.ts +2714 -0
- package/src/generated/sid/v1/scim/scim.client.ts +274 -0
- package/src/generated/sid/v1/scim/scim.ts +3043 -0
- package/src/generated/sid/v1/test/test.client.ts +232 -0
- package/src/generated/sid/v1/test/test.ts +1136 -0
- package/src/i18n/index.ts +40 -0
- package/src/index.ts +157 -0
- package/src/profile/auth.ts +15 -0
- package/src/profile/components/ConsentManager.vue +88 -0
- package/src/profile/components/ProfileEditor.vue +146 -0
- package/src/profile/components/SecuritySettings.vue +170 -0
- package/src/profile/components/SessionList.vue +100 -0
- package/src/profile/components/SidLoginForm.test.ts +80 -0
- package/src/profile/components/SidLoginForm.vue +230 -0
- package/src/profile/components/SidMfaChallenge.test.ts +127 -0
- package/src/profile/components/SidMfaChallenge.vue +243 -0
- package/src/profile/components/SidRegistrationForm.test.ts +79 -0
- package/src/profile/components/SidRegistrationForm.vue +317 -0
- package/src/profile/components/index.ts +11 -0
- package/src/profile/composables/index.ts +6 -0
- package/src/profile/composables/useAccountApi.ts +203 -0
- package/src/profile/composables/useAuthApi.opaque.test.ts +140 -0
- package/src/profile/composables/useAuthApi.ts +617 -0
- package/src/profile/composables/useIdentityApi.ts +123 -0
- package/src/profile/composables/useLoginSuccess.test.ts +112 -0
- package/src/profile/composables/useLoginSuccess.ts +65 -0
- package/src/profile/composables/useOpaqueAuth.test.ts +290 -0
- package/src/profile/composables/useOpaqueAuth.ts +191 -0
- package/src/profile/composables/useSecurityLevel.test.ts +198 -0
- package/src/profile/composables/useSecurityLevel.ts +324 -0
- package/src/profile/index.ts +18 -0
- package/src/quasar/SidPrincipalInput.vue +209 -0
- package/src/quasar/index.ts +8 -0
- package/src/stores/auth.test.ts +83 -0
- package/src/stores/auth.ts +92 -0
- package/src/stores/index.ts +1 -0
- package/src/transport/adaptive.test.ts +728 -0
- package/src/transport/adaptive.ts +308 -0
- package/src/transport/framing.test.ts +227 -0
- package/src/transport/framing.ts +98 -0
- package/src/transport/webtransport.test.ts +728 -0
- package/src/transport/webtransport.ts +323 -0
- package/src/types/auth.ts +58 -0
- package/src/types/index.ts +1 -0
- package/src/utils/formatters.test.ts +26 -0
- package/src/utils/formatters.ts +69 -0
- package/src/utils/index.ts +16 -0
- package/src/utils/normalizePrincipal.test.ts +188 -0
- package/src/utils/normalizePrincipal.ts +159 -0
- package/src/utils/validators.test.ts +75 -0
- package/src/utils/validators.ts +36 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<q-banner v-if="error" class="bg-negative text-white q-mb-md" rounded dense>
|
|
4
|
+
{{ error }}
|
|
5
|
+
</q-banner>
|
|
6
|
+
|
|
7
|
+
<div v-if="loading" class="text-center q-pa-lg">
|
|
8
|
+
<q-spinner-dots size="32px" color="primary" />
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
<q-list v-else-if="sessions.length > 0" separator>
|
|
12
|
+
<q-item v-for="session in sessions" :key="session.id">
|
|
13
|
+
<q-item-section avatar>
|
|
14
|
+
<q-icon :name="deviceIcon(session.userAgent)" size="sm" />
|
|
15
|
+
</q-item-section>
|
|
16
|
+
<q-item-section>
|
|
17
|
+
<q-item-label>
|
|
18
|
+
{{ parseUserAgent(session.userAgent) }}
|
|
19
|
+
<q-badge
|
|
20
|
+
v-if="isCurrentSession(session)"
|
|
21
|
+
color="positive"
|
|
22
|
+
label="Current"
|
|
23
|
+
class="q-ml-sm"
|
|
24
|
+
/>
|
|
25
|
+
</q-item-label>
|
|
26
|
+
<q-item-label caption>
|
|
27
|
+
<span v-if="session.ipAddress">{{ session.ipAddress }}</span>
|
|
28
|
+
<span v-if="session.lastActivityAt">
|
|
29
|
+
· {{ formatRelative(session.lastActivityAt) }}
|
|
30
|
+
</span>
|
|
31
|
+
</q-item-label>
|
|
32
|
+
</q-item-section>
|
|
33
|
+
<q-item-section side>
|
|
34
|
+
<q-btn
|
|
35
|
+
v-if="!isCurrentSession(session)"
|
|
36
|
+
flat
|
|
37
|
+
dense
|
|
38
|
+
color="negative"
|
|
39
|
+
icon="sym_o_logout"
|
|
40
|
+
:loading="revoking === session.id"
|
|
41
|
+
@click="$emit('revoke', session.id)"
|
|
42
|
+
/>
|
|
43
|
+
</q-item-section>
|
|
44
|
+
</q-item>
|
|
45
|
+
</q-list>
|
|
46
|
+
|
|
47
|
+
<div v-else class="text-center text-grey q-pa-lg">No active sessions</div>
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
|
|
51
|
+
<script setup lang="ts">
|
|
52
|
+
import { type Session, Timestamp } from "../../index";
|
|
53
|
+
|
|
54
|
+
defineProps<{
|
|
55
|
+
sessions: Session[];
|
|
56
|
+
loading: boolean;
|
|
57
|
+
error: string | null;
|
|
58
|
+
revoking: string | null;
|
|
59
|
+
currentSessionId?: string;
|
|
60
|
+
}>();
|
|
61
|
+
|
|
62
|
+
defineEmits<{
|
|
63
|
+
revoke: [sessionId: string];
|
|
64
|
+
}>();
|
|
65
|
+
|
|
66
|
+
function isCurrentSession(session: Session): boolean {
|
|
67
|
+
return false; // Overridden via currentSessionId prop comparison
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function deviceIcon(userAgent: string | undefined): string {
|
|
71
|
+
if (!userAgent) return "sym_o_devices";
|
|
72
|
+
const ua = userAgent.toLowerCase();
|
|
73
|
+
if (ua.includes("mobile") || ua.includes("android") || ua.includes("iphone"))
|
|
74
|
+
return "sym_o_smartphone";
|
|
75
|
+
if (ua.includes("tablet") || ua.includes("ipad")) return "sym_o_tablet";
|
|
76
|
+
return "sym_o_computer";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseUserAgent(userAgent: string | undefined): string {
|
|
80
|
+
if (!userAgent) return "Unknown device";
|
|
81
|
+
if (userAgent.includes("Chrome")) return "Chrome";
|
|
82
|
+
if (userAgent.includes("Firefox")) return "Firefox";
|
|
83
|
+
if (userAgent.includes("Safari")) return "Safari";
|
|
84
|
+
if (userAgent.includes("Edge")) return "Edge";
|
|
85
|
+
return userAgent.slice(0, 40);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function formatRelative(ts?: { seconds: bigint; nanos: number }): string {
|
|
89
|
+
if (!ts) return "";
|
|
90
|
+
const date = Timestamp.toDate(ts);
|
|
91
|
+
const diff = Date.now() - date.getTime();
|
|
92
|
+
const mins = Math.floor(diff / 60_000);
|
|
93
|
+
if (mins < 1) return "just now";
|
|
94
|
+
if (mins < 60) return `${mins}m ago`;
|
|
95
|
+
const hours = Math.floor(mins / 60);
|
|
96
|
+
if (hours < 24) return `${hours}h ago`;
|
|
97
|
+
const days = Math.floor(hours / 24);
|
|
98
|
+
return `${days}d ago`;
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import { defineComponent } from "vue";
|
|
4
|
+
import SidLoginForm from "./SidLoginForm.vue";
|
|
5
|
+
|
|
6
|
+
// Stub all Quasar components as divs
|
|
7
|
+
const quasarStubs = {
|
|
8
|
+
"q-card": { template: "<div><slot /></div>" },
|
|
9
|
+
"q-card-section": { template: "<div><slot /></div>" },
|
|
10
|
+
"q-separator": { template: "<hr />" },
|
|
11
|
+
"q-banner": { template: "<div><slot /></div>" },
|
|
12
|
+
"q-linear-progress": { template: "<div />" },
|
|
13
|
+
"q-icon": { template: "<span />" },
|
|
14
|
+
"q-form": {
|
|
15
|
+
template: "<form @submit.prevent=\"$emit('submit')\"><slot /></form>",
|
|
16
|
+
emits: ["submit"],
|
|
17
|
+
},
|
|
18
|
+
"q-input": defineComponent({
|
|
19
|
+
props: ["modelValue", "type", "label", "disable", "rules"],
|
|
20
|
+
emits: ["update:modelValue"],
|
|
21
|
+
template:
|
|
22
|
+
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
23
|
+
}),
|
|
24
|
+
"q-btn": { template: '<button type="submit"><slot /></button>' },
|
|
25
|
+
"sid-principal-input": defineComponent({
|
|
26
|
+
props: ["modelValue", "disable"],
|
|
27
|
+
emits: ["update:modelValue", "update:principal-type"],
|
|
28
|
+
template:
|
|
29
|
+
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
30
|
+
}),
|
|
31
|
+
"router-link": { template: "<a><slot /></a>" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const loginFn = vi.fn().mockResolvedValue({
|
|
35
|
+
accessToken: "tok",
|
|
36
|
+
expiresIn: 3600,
|
|
37
|
+
sessionId: "sess-1",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function mountLogin(props = {}) {
|
|
41
|
+
return mount(SidLoginForm, {
|
|
42
|
+
props: { loginFn, ...props },
|
|
43
|
+
global: { stubs: quasarStubs },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("SidLoginForm", () => {
|
|
48
|
+
it("renders with default title", () => {
|
|
49
|
+
const w = mountLogin();
|
|
50
|
+
expect(w.text()).toContain("Sign in");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders custom title via prop", () => {
|
|
54
|
+
const w = mountLogin({ title: "Welcome back" });
|
|
55
|
+
expect(w.text()).toContain("Welcome back");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders header slot", () => {
|
|
59
|
+
const w = mount(SidLoginForm, {
|
|
60
|
+
props: { loginFn },
|
|
61
|
+
global: { stubs: quasarStubs },
|
|
62
|
+
slots: { header: "<div>Custom Header</div>" },
|
|
63
|
+
});
|
|
64
|
+
expect(w.text()).toContain("Custom Header");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("renders footer slot", () => {
|
|
68
|
+
const w = mount(SidLoginForm, {
|
|
69
|
+
props: { loginFn },
|
|
70
|
+
global: { stubs: quasarStubs },
|
|
71
|
+
slots: { footer: "<div>Custom Footer</div>" },
|
|
72
|
+
});
|
|
73
|
+
expect(w.text()).toContain("Custom Footer");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("shows zero-knowledge explainer", () => {
|
|
77
|
+
const w = mountLogin();
|
|
78
|
+
expect(w.text()).toContain("Your password never leaves this device");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<q-card flat bordered>
|
|
3
|
+
<q-card-section>
|
|
4
|
+
<slot name="header">
|
|
5
|
+
<div class="text-h6">{{ title }}</div>
|
|
6
|
+
<div class="text-caption text-grey">{{ subtitle }}</div>
|
|
7
|
+
</slot>
|
|
8
|
+
</q-card-section>
|
|
9
|
+
|
|
10
|
+
<q-separator />
|
|
11
|
+
|
|
12
|
+
<q-card-section>
|
|
13
|
+
<q-banner
|
|
14
|
+
v-if="error"
|
|
15
|
+
class="bg-negative text-white q-mb-md"
|
|
16
|
+
rounded
|
|
17
|
+
dense
|
|
18
|
+
>
|
|
19
|
+
{{ error }}
|
|
20
|
+
</q-banner>
|
|
21
|
+
|
|
22
|
+
<slot name="progress" :step="opaqueStep" :label="opaqueStepLabel">
|
|
23
|
+
<div v-if="opaqueStep" class="q-mb-md">
|
|
24
|
+
<q-linear-progress
|
|
25
|
+
:value="opaqueStep / 3"
|
|
26
|
+
color="primary"
|
|
27
|
+
class="q-mb-sm"
|
|
28
|
+
/>
|
|
29
|
+
<div class="text-caption text-center text-grey-7">
|
|
30
|
+
<q-icon name="sym_o_lock" size="xs" class="q-mr-xs" />
|
|
31
|
+
{{ opaqueStepLabel }}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</slot>
|
|
35
|
+
|
|
36
|
+
<q-form @submit.prevent="onSubmit" class="q-gutter-y-md">
|
|
37
|
+
<sid-principal-input
|
|
38
|
+
v-model="identifier"
|
|
39
|
+
:disable="loading"
|
|
40
|
+
:label="identifierLabel"
|
|
41
|
+
@update:principal-type="principalType = $event"
|
|
42
|
+
/>
|
|
43
|
+
|
|
44
|
+
<q-input
|
|
45
|
+
v-model="password"
|
|
46
|
+
:type="showPassword ? 'text' : 'password'"
|
|
47
|
+
:label="passwordLabel"
|
|
48
|
+
outlined
|
|
49
|
+
:disable="loading"
|
|
50
|
+
:rules="[(v: string) => !!v || passwordRequiredText]"
|
|
51
|
+
>
|
|
52
|
+
<template v-slot:prepend>
|
|
53
|
+
<q-icon name="sym_o_lock" />
|
|
54
|
+
</template>
|
|
55
|
+
<template v-slot:append>
|
|
56
|
+
<q-icon
|
|
57
|
+
:name="showPassword ? 'sym_o_visibility_off' : 'sym_o_visibility'"
|
|
58
|
+
class="cursor-pointer"
|
|
59
|
+
@click="showPassword = !showPassword"
|
|
60
|
+
/>
|
|
61
|
+
</template>
|
|
62
|
+
</q-input>
|
|
63
|
+
|
|
64
|
+
<slot name="extra-fields" />
|
|
65
|
+
|
|
66
|
+
<q-btn
|
|
67
|
+
type="submit"
|
|
68
|
+
:label="submitLabel"
|
|
69
|
+
color="primary"
|
|
70
|
+
class="full-width"
|
|
71
|
+
:loading="loading"
|
|
72
|
+
:disable="!identifier || !password"
|
|
73
|
+
/>
|
|
74
|
+
</q-form>
|
|
75
|
+
|
|
76
|
+
<slot name="below-form">
|
|
77
|
+
<div v-if="showForgotPassword" class="text-right q-mt-xs">
|
|
78
|
+
<router-link
|
|
79
|
+
v-if="forgotPasswordUrl"
|
|
80
|
+
:to="forgotPasswordUrl"
|
|
81
|
+
class="text-caption text-primary"
|
|
82
|
+
>
|
|
83
|
+
{{ forgotPasswordText }}
|
|
84
|
+
</router-link>
|
|
85
|
+
<a
|
|
86
|
+
v-else
|
|
87
|
+
class="text-caption text-primary cursor-pointer"
|
|
88
|
+
@click="$emit('forgot-password')"
|
|
89
|
+
>
|
|
90
|
+
{{ forgotPasswordText }}
|
|
91
|
+
</a>
|
|
92
|
+
</div>
|
|
93
|
+
</slot>
|
|
94
|
+
|
|
95
|
+
<div class="text-caption text-grey-6 text-center q-mt-md">
|
|
96
|
+
<q-icon name="sym_o_shield" size="xs" class="q-mr-xs" />
|
|
97
|
+
{{ securityNotice }}
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<slot name="links">
|
|
101
|
+
<div v-if="registerUrl" class="text-center q-mt-md text-body2">
|
|
102
|
+
{{ noAccountText }}
|
|
103
|
+
<router-link :to="registerUrl" class="text-primary">
|
|
104
|
+
{{ registerLinkText }}
|
|
105
|
+
</router-link>
|
|
106
|
+
</div>
|
|
107
|
+
</slot>
|
|
108
|
+
</q-card-section>
|
|
109
|
+
|
|
110
|
+
<slot name="footer" />
|
|
111
|
+
</q-card>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<script setup lang="ts">
|
|
115
|
+
import { ref } from "vue";
|
|
116
|
+
import { SidPrincipalInput } from "../../quasar";
|
|
117
|
+
import type { PrincipalType } from "../../quasar";
|
|
118
|
+
import { normalizePrincipal } from "../../index";
|
|
119
|
+
|
|
120
|
+
void SidPrincipalInput;
|
|
121
|
+
|
|
122
|
+
const props = withDefaults(
|
|
123
|
+
defineProps<{
|
|
124
|
+
/** Card title */
|
|
125
|
+
title?: string;
|
|
126
|
+
/** Card subtitle */
|
|
127
|
+
subtitle?: string;
|
|
128
|
+
/** Submit button label */
|
|
129
|
+
submitLabel?: string;
|
|
130
|
+
/** Identifier input label */
|
|
131
|
+
identifierLabel?: string;
|
|
132
|
+
/** Password input label */
|
|
133
|
+
passwordLabel?: string;
|
|
134
|
+
/** Password required validation text */
|
|
135
|
+
passwordRequiredText?: string;
|
|
136
|
+
/** Show "Forgot password?" link */
|
|
137
|
+
showForgotPassword?: boolean;
|
|
138
|
+
/** Forgot password link text */
|
|
139
|
+
forgotPasswordText?: string;
|
|
140
|
+
/** Forgot password route (if set, renders router-link instead of emitting event) */
|
|
141
|
+
forgotPasswordUrl?: string;
|
|
142
|
+
/** Security disclaimer text */
|
|
143
|
+
securityNotice?: string;
|
|
144
|
+
/** "Don't have an account?" text */
|
|
145
|
+
noAccountText?: string;
|
|
146
|
+
/** "Create one" link text */
|
|
147
|
+
registerLinkText?: string;
|
|
148
|
+
/** Route to registration page (if set, shows register link) */
|
|
149
|
+
registerUrl?: string;
|
|
150
|
+
/** OPAQUE step labels (step 1-3) */
|
|
151
|
+
opaqueStepLabels?: Record<number, string>;
|
|
152
|
+
/** External login function — receives (identifier, password, stepCallback) */
|
|
153
|
+
loginFn: (
|
|
154
|
+
identifier: string,
|
|
155
|
+
password: string,
|
|
156
|
+
onStep?: (step: number) => void,
|
|
157
|
+
) => Promise<{
|
|
158
|
+
accessToken: string;
|
|
159
|
+
expiresIn: number;
|
|
160
|
+
sessionId?: string;
|
|
161
|
+
}>;
|
|
162
|
+
}>(),
|
|
163
|
+
{
|
|
164
|
+
title: "Sign in",
|
|
165
|
+
subtitle: "Enter your email, phone, or username to continue",
|
|
166
|
+
submitLabel: "Sign in",
|
|
167
|
+
identifierLabel: "Email, phone, or username",
|
|
168
|
+
passwordLabel: "Password",
|
|
169
|
+
passwordRequiredText: "Required",
|
|
170
|
+
showForgotPassword: true,
|
|
171
|
+
forgotPasswordText: "Forgot password?",
|
|
172
|
+
forgotPasswordUrl: undefined,
|
|
173
|
+
securityNotice: "Your password never leaves this device",
|
|
174
|
+
noAccountText: "Don\u2019t have an account?",
|
|
175
|
+
registerLinkText: "Create one",
|
|
176
|
+
registerUrl: undefined,
|
|
177
|
+
opaqueStepLabels: () => ({
|
|
178
|
+
1: "Computing credential request\u2026",
|
|
179
|
+
2: "Verifying with server (password stays local)\u2026",
|
|
180
|
+
3: "Establishing secure session\u2026",
|
|
181
|
+
}),
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
const emit = defineEmits<{
|
|
186
|
+
success: [
|
|
187
|
+
result: { accessToken: string; expiresIn: number; sessionId?: string },
|
|
188
|
+
];
|
|
189
|
+
error: [error: Error];
|
|
190
|
+
"forgot-password": [];
|
|
191
|
+
}>();
|
|
192
|
+
|
|
193
|
+
const identifier = ref("");
|
|
194
|
+
const principalType = ref<PrincipalType>("unknown");
|
|
195
|
+
const password = ref("");
|
|
196
|
+
const showPassword = ref(false);
|
|
197
|
+
const loading = ref(false);
|
|
198
|
+
const error = ref<string | null>(null);
|
|
199
|
+
const opaqueStep = ref(0);
|
|
200
|
+
const opaqueStepLabel = ref("");
|
|
201
|
+
|
|
202
|
+
function setStep(step: number) {
|
|
203
|
+
opaqueStep.value = step;
|
|
204
|
+
opaqueStepLabel.value = props.opaqueStepLabels?.[step] || "";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function onSubmit() {
|
|
208
|
+
loading.value = true;
|
|
209
|
+
error.value = null;
|
|
210
|
+
opaqueStep.value = 0;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const normalized = normalizePrincipal(identifier.value);
|
|
214
|
+
setStep(1);
|
|
215
|
+
const result = await props.loginFn(
|
|
216
|
+
normalized.value,
|
|
217
|
+
password.value,
|
|
218
|
+
setStep,
|
|
219
|
+
);
|
|
220
|
+
emit("success", result);
|
|
221
|
+
} catch (e) {
|
|
222
|
+
const err = e instanceof Error ? e : new Error("Login failed");
|
|
223
|
+
error.value = err.message;
|
|
224
|
+
emit("error", err);
|
|
225
|
+
} finally {
|
|
226
|
+
loading.value = false;
|
|
227
|
+
opaqueStep.value = 0;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
</script>
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mount } from "@vue/test-utils";
|
|
3
|
+
import SidMfaChallenge from "./SidMfaChallenge.vue";
|
|
4
|
+
import type { MfaMethod } from "./SidMfaChallenge.vue";
|
|
5
|
+
|
|
6
|
+
/** Exposed API declared via defineExpose() in SidMfaChallenge.vue. */
|
|
7
|
+
interface MfaChallengeExposed {
|
|
8
|
+
setSuccess: () => void;
|
|
9
|
+
setError: (message: string) => void;
|
|
10
|
+
reset: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const quasarStubs = {
|
|
14
|
+
"q-card": { template: "<div><slot /></div>" },
|
|
15
|
+
"q-card-section": { template: "<div><slot /></div>" },
|
|
16
|
+
"q-separator": { template: "<hr />" },
|
|
17
|
+
"q-banner": { template: "<div><slot /></div>" },
|
|
18
|
+
"q-icon": { template: "<span />" },
|
|
19
|
+
"q-list": { template: "<div><slot /></div>" },
|
|
20
|
+
"q-item": {
|
|
21
|
+
template: "<div @click=\"$emit('click')\"><slot /></div>",
|
|
22
|
+
emits: ["click"],
|
|
23
|
+
},
|
|
24
|
+
"q-item-section": { template: "<div><slot /></div>" },
|
|
25
|
+
"q-item-label": { template: "<div><slot /></div>" },
|
|
26
|
+
"q-badge": { template: "<span />" },
|
|
27
|
+
"q-form": {
|
|
28
|
+
template: "<form @submit.prevent=\"$emit('submit')\"><slot /></form>",
|
|
29
|
+
emits: ["submit"],
|
|
30
|
+
},
|
|
31
|
+
"q-input": {
|
|
32
|
+
props: ["modelValue"],
|
|
33
|
+
emits: ["update:modelValue"],
|
|
34
|
+
template:
|
|
35
|
+
'<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />',
|
|
36
|
+
},
|
|
37
|
+
"q-btn": { template: '<button type="submit"><slot /></button>' },
|
|
38
|
+
"q-spinner-dots": { template: "<span />" },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const methods: MfaMethod[] = [
|
|
42
|
+
{
|
|
43
|
+
type: "totp",
|
|
44
|
+
icon: "sym_o_timer",
|
|
45
|
+
label: "Authenticator app",
|
|
46
|
+
description: "Use your TOTP app",
|
|
47
|
+
color: "primary",
|
|
48
|
+
recommended: true,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
type: "webauthn",
|
|
52
|
+
icon: "sym_o_passkey",
|
|
53
|
+
label: "Passkey",
|
|
54
|
+
description: "Use a passkey",
|
|
55
|
+
color: "purple",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
type: "recovery",
|
|
59
|
+
icon: "sym_o_key",
|
|
60
|
+
label: "Recovery code",
|
|
61
|
+
description: "Use a recovery code",
|
|
62
|
+
color: "warning",
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
function mountChallenge(props = {}) {
|
|
67
|
+
return mount(SidMfaChallenge, {
|
|
68
|
+
props: { methods, ...props },
|
|
69
|
+
global: { stubs: quasarStubs },
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("SidMfaChallenge", () => {
|
|
74
|
+
it("renders method selection by default", () => {
|
|
75
|
+
const w = mountChallenge();
|
|
76
|
+
expect(w.text()).toContain("Authenticator app");
|
|
77
|
+
expect(w.text()).toContain("Passkey");
|
|
78
|
+
expect(w.text()).toContain("Recovery code");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("renders default title", () => {
|
|
82
|
+
const w = mountChallenge();
|
|
83
|
+
expect(w.text()).toContain("Additional verification required");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders custom title", () => {
|
|
87
|
+
const w = mountChallenge({ title: "Verify identity" });
|
|
88
|
+
expect(w.text()).toContain("Verify identity");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders custom reason", () => {
|
|
92
|
+
const w = mountChallenge({ reason: "Deleting your account" });
|
|
93
|
+
expect(w.text()).toContain("Deleting your account");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("exposes phase control methods", () => {
|
|
97
|
+
const w = mountChallenge();
|
|
98
|
+
expect(typeof w.vm.setLoading).toBe("function");
|
|
99
|
+
expect(typeof w.vm.setError).toBe("function");
|
|
100
|
+
expect(typeof w.vm.setSuccess).toBe("function");
|
|
101
|
+
expect(typeof w.vm.reset).toBe("function");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("setSuccess transitions to success phase", async () => {
|
|
105
|
+
const w = mountChallenge();
|
|
106
|
+
(w.vm as unknown as MfaChallengeExposed).setSuccess();
|
|
107
|
+
await w.vm.$nextTick();
|
|
108
|
+
expect(w.text()).toContain("Verified");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("setError shows error banner", async () => {
|
|
112
|
+
const w = mountChallenge();
|
|
113
|
+
(w.vm as unknown as MfaChallengeExposed).setError("Invalid code");
|
|
114
|
+
await w.vm.$nextTick();
|
|
115
|
+
expect(w.text()).toContain("Invalid code");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("reset returns to method selection", async () => {
|
|
119
|
+
const w = mountChallenge();
|
|
120
|
+
(w.vm as unknown as MfaChallengeExposed).setSuccess();
|
|
121
|
+
await w.vm.$nextTick();
|
|
122
|
+
expect(w.text()).toContain("Verified");
|
|
123
|
+
(w.vm as unknown as MfaChallengeExposed).reset();
|
|
124
|
+
await w.vm.$nextTick();
|
|
125
|
+
expect(w.text()).toContain("Authenticator app");
|
|
126
|
+
});
|
|
127
|
+
});
|