@frigear-nu/account 0.0.7 → 0.0.9
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/README.md +11 -6
- package/dist/module.d.mts +20 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +35 -40
- package/dist/runtime/app/components/Account/AccountHeader.d.vue.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeader.vue +43 -0
- package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeaderRight.d.vue.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeaderRight.vue +18 -0
- package/dist/runtime/app/components/Account/AccountHeaderRight.vue.d.ts +3 -0
- package/dist/runtime/app/components/Account/AccountMenu.vue +7 -2
- package/dist/runtime/app/composables/useAccount.js +3 -3
- package/dist/runtime/app/pages/api-tokens.d.vue.ts +3 -0
- package/dist/runtime/app/pages/api-tokens.vue +308 -0
- package/dist/runtime/app/pages/api-tokens.vue.d.ts +3 -0
- package/dist/runtime/app/pages/documents.d.vue.ts +3 -0
- package/dist/runtime/app/pages/documents.vue +38 -0
- package/dist/runtime/app/pages/documents.vue.d.ts +3 -0
- package/dist/runtime/app/pages/layout.d.vue.ts +3 -0
- package/dist/runtime/app/pages/layout.vue +10 -0
- package/dist/runtime/app/pages/layout.vue.d.ts +3 -0
- package/dist/runtime/app/pages/profile.vue +188 -46
- package/dist/runtime/app/pages/security.vue +180 -60
- package/dist/runtime/app/utils/documents.d.ts +5 -0
- package/dist/runtime/app/utils/documents.js +7 -0
- package/dist/runtime/i18n/lang/da.json +120 -16
- package/dist/runtime/i18n/lang/en.json +120 -16
- package/dist/runtime/menus.js +24 -0
- package/package.json +20 -10
- package/dist/runtime/server/db/schema.sqlite.d.ts +0 -24
- package/dist/runtime/server/db/schema.sqlite.js +0 -4
- package/dist/runtime/server/routes/account/change-email.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/change-email.post.js +0 -20
- package/dist/runtime/server/routes/account/change-password.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/change-password.post.js +0 -26
- package/dist/runtime/server/routes/account/profile.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/profile.post.js +0 -17
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { documentLabel, visibleDocuments } from "../utils/documents";
|
|
3
|
+
defineOptions({ name: "AccountDocumentsPage" });
|
|
4
|
+
const t = useModuleT("account.documents");
|
|
5
|
+
const { user } = useUserSession();
|
|
6
|
+
const nuxtApp = useNuxtApp();
|
|
7
|
+
const locale = computed(() => nuxtApp.$i18n?.locale?.value ?? "en");
|
|
8
|
+
const documents = computed(() => visibleDocuments(useRuntimeConfig().public.account?.documents ?? [], user.value?.role));
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<template>
|
|
12
|
+
<UContainer>
|
|
13
|
+
<UPage>
|
|
14
|
+
<UPageBody>
|
|
15
|
+
<UPageGrid v-if="documents.length">
|
|
16
|
+
<UPageCard
|
|
17
|
+
v-for="document in documents"
|
|
18
|
+
:key="document.id"
|
|
19
|
+
:title="documentLabel(document, locale)"
|
|
20
|
+
:icon="document.icon ?? 'i-lucide-file'"
|
|
21
|
+
>
|
|
22
|
+
<UButton
|
|
23
|
+
download
|
|
24
|
+
external
|
|
25
|
+
:to="document.path"
|
|
26
|
+
icon="i-lucide-download"
|
|
27
|
+
:label="t('download', 'Download')"
|
|
28
|
+
/>
|
|
29
|
+
</UPageCard>
|
|
30
|
+
</UPageGrid>
|
|
31
|
+
<UEmpty
|
|
32
|
+
v-else
|
|
33
|
+
:title="t('empty', 'No documents for you yet.')"
|
|
34
|
+
/>
|
|
35
|
+
</UPageBody>
|
|
36
|
+
</UPage>
|
|
37
|
+
</UContainer>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -1,22 +1,62 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
defineOptions({ name: "AccountProfilePage" });
|
|
3
4
|
const t = useModuleT("account.profile");
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
const route = useRoute();
|
|
4
7
|
const { user } = useUserSession();
|
|
5
|
-
const { updateProfileDetails, changeEmailAddress,
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
watch(user, (newUser) => {
|
|
10
|
-
if (!newUser) return;
|
|
11
|
-
name.value = newUser.name;
|
|
12
|
-
avatar.value = newUser.avatar;
|
|
13
|
-
email.value = newUser.email;
|
|
8
|
+
const { updateProfileDetails, changeEmailAddress, error } = useAccount();
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
name: z.string().trim().min(1, t("name.required", "Please enter your name.")),
|
|
11
|
+
avatar: z.union([z.literal(""), z.url(t("avatar.invalid", "Enter a valid URL."))]).optional()
|
|
14
12
|
});
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
const state = reactive({
|
|
14
|
+
name: user.value?.name ?? "",
|
|
15
|
+
avatar: user.value?.avatar ?? ""
|
|
16
|
+
});
|
|
17
|
+
const saved = computed(() => ({ name: user.value?.name ?? "", avatar: user.value?.avatar ?? "" }));
|
|
18
|
+
let lastSynced = { ...saved.value };
|
|
19
|
+
watch(saved, (next) => {
|
|
20
|
+
const pristine = state.name === lastSynced.name && state.avatar === lastSynced.avatar;
|
|
21
|
+
lastSynced = { ...next };
|
|
22
|
+
if (pristine) {
|
|
23
|
+
state.name = next.name;
|
|
24
|
+
state.avatar = next.avatar;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
const dirty = computed(() => state.name !== saved.value.name || state.avatar !== saved.value.avatar);
|
|
28
|
+
const saving = ref(false);
|
|
29
|
+
async function onSubmitProfile(event) {
|
|
30
|
+
saving.value = true;
|
|
31
|
+
try {
|
|
32
|
+
await updateProfileDetails({ name: event.data.name, avatar: event.data.avatar || void 0 });
|
|
33
|
+
toast.add({ title: t("saved", "Profile updated"), color: "success" });
|
|
34
|
+
} catch (e) {
|
|
35
|
+
toast.add({ title: e.statusMessage ?? t("saveFailed", "Could not update your profile."), color: "error" });
|
|
36
|
+
} finally {
|
|
37
|
+
saving.value = false;
|
|
38
|
+
}
|
|
17
39
|
}
|
|
40
|
+
const emailModalOpen = ref(false);
|
|
41
|
+
const newEmail = ref("");
|
|
42
|
+
const emailPending = ref(false);
|
|
43
|
+
const emailVerificationSent = ref(false);
|
|
44
|
+
const emailChanged = computed(() => route.query["email-changed"] === "1");
|
|
45
|
+
const emailChangeFailed = computed(() => route.query.error === "email-change");
|
|
46
|
+
const emailSchema = z.object({ email: z.email(t("email.invalid", "Enter a valid email address.")) });
|
|
18
47
|
async function onSubmitEmail() {
|
|
19
|
-
|
|
48
|
+
emailPending.value = true;
|
|
49
|
+
try {
|
|
50
|
+
const result = await changeEmailAddress({ email: newEmail.value });
|
|
51
|
+
emailVerificationSent.value = result.verificationRequired;
|
|
52
|
+
emailModalOpen.value = false;
|
|
53
|
+
newEmail.value = "";
|
|
54
|
+
toast.add({ title: result.verificationRequired ? t("email.change.sent.title", "Check your inbox") : t("email.change.done", "Your email address was changed."), color: "success" });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
toast.add({ title: e.statusMessage ?? t("email.change.error", "Could not change your email address."), color: "error" });
|
|
57
|
+
} finally {
|
|
58
|
+
emailPending.value = false;
|
|
59
|
+
}
|
|
20
60
|
}
|
|
21
61
|
</script>
|
|
22
62
|
|
|
@@ -24,52 +64,154 @@ async function onSubmitEmail() {
|
|
|
24
64
|
<UContainer>
|
|
25
65
|
<UPage>
|
|
26
66
|
<UPageBody>
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
67
|
+
<UAlert
|
|
68
|
+
v-if="emailChanged"
|
|
69
|
+
color="success"
|
|
70
|
+
variant="subtle"
|
|
71
|
+
:title="t('email.change.done', 'Your email address was changed.')"
|
|
72
|
+
/>
|
|
73
|
+
<UAlert
|
|
74
|
+
v-if="emailChangeFailed"
|
|
75
|
+
color="error"
|
|
76
|
+
variant="subtle"
|
|
77
|
+
:title="t('email.change.failed', 'This confirmation link is invalid or has expired.')"
|
|
78
|
+
/>
|
|
79
|
+
<UAlert
|
|
80
|
+
v-if="emailVerificationSent"
|
|
81
|
+
variant="subtle"
|
|
82
|
+
:title="t('email.change.sent.title', 'Check your inbox')"
|
|
83
|
+
:description="t('email.change.sent.description', 'We sent a link to your new address. It takes effect once you confirm it.')"
|
|
84
|
+
/>
|
|
85
|
+
|
|
86
|
+
<UForm
|
|
87
|
+
id="profile"
|
|
88
|
+
:schema="schema"
|
|
89
|
+
:state="state"
|
|
90
|
+
@submit="onSubmitProfile"
|
|
91
|
+
>
|
|
92
|
+
<UPageCard
|
|
93
|
+
variant="subtle"
|
|
94
|
+
:title="t('title', 'Profile')"
|
|
95
|
+
:description="t('description', 'Change your information.')"
|
|
37
96
|
>
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
97
|
+
<UFormField
|
|
98
|
+
name="name"
|
|
99
|
+
:label="t('name.label', 'Name')"
|
|
100
|
+
:description="t('name.description', 'Your full name.')"
|
|
101
|
+
required
|
|
102
|
+
class="flex items-start justify-between gap-4 max-sm:flex-col"
|
|
103
|
+
>
|
|
104
|
+
<UInput
|
|
105
|
+
v-model="state.name"
|
|
106
|
+
autocomplete="name"
|
|
107
|
+
/>
|
|
108
|
+
</UFormField>
|
|
109
|
+
<USeparator />
|
|
110
|
+
<UFormField
|
|
111
|
+
name="avatar"
|
|
112
|
+
:label="t('avatar.label', 'Avatar')"
|
|
113
|
+
:description="t('avatar.description', 'Link to a square image.')"
|
|
114
|
+
class="flex items-start justify-between gap-4 max-sm:flex-col"
|
|
115
|
+
>
|
|
116
|
+
<div class="flex items-center gap-3">
|
|
117
|
+
<UAvatar
|
|
118
|
+
:src="state.avatar || void 0"
|
|
119
|
+
:alt="state.name"
|
|
120
|
+
size="xl"
|
|
121
|
+
/>
|
|
122
|
+
<UInput
|
|
123
|
+
v-model="state.avatar"
|
|
124
|
+
type="url"
|
|
125
|
+
placeholder="https://"
|
|
126
|
+
:aria-label="t('avatar.url', 'Avatar URL')"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
</UFormField>
|
|
130
|
+
<USeparator />
|
|
131
|
+
<div class="flex justify-end">
|
|
132
|
+
<UButton
|
|
133
|
+
type="submit"
|
|
134
|
+
:loading="saving"
|
|
135
|
+
:disabled="!dirty"
|
|
136
|
+
:label="t('save', 'Save profile')"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
</UPageCard>
|
|
140
|
+
</UForm>
|
|
141
|
+
|
|
47
142
|
<UPageCard
|
|
48
|
-
|
|
49
|
-
:title="t('email.label', 'Email
|
|
143
|
+
variant="subtle"
|
|
144
|
+
:title="t('email.label', 'Email')"
|
|
145
|
+
:description="t('email.description', 'Your email address.')"
|
|
50
146
|
>
|
|
51
|
-
<
|
|
52
|
-
:label="t('email.label', 'Email')"
|
|
53
|
-
name="email"
|
|
54
|
-
>
|
|
147
|
+
<div class="flex items-center justify-between gap-4 max-sm:flex-col max-sm:items-start">
|
|
55
148
|
<UInput
|
|
56
|
-
|
|
149
|
+
:model-value="user?.email ?? ''"
|
|
57
150
|
type="email"
|
|
151
|
+
readonly
|
|
152
|
+
disabled
|
|
153
|
+
class="w-full sm:max-w-xs"
|
|
154
|
+
:aria-label="t('email.label', 'Email')"
|
|
58
155
|
/>
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
156
|
+
<UButton
|
|
157
|
+
variant="subtle"
|
|
158
|
+
trailing-icon="i-lucide-pencil"
|
|
159
|
+
:label="t('email.change.title', 'Change email')"
|
|
160
|
+
@click="emailModalOpen = true"
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
66
163
|
</UPageCard>
|
|
164
|
+
|
|
67
165
|
<UAlert
|
|
68
166
|
v-if="error"
|
|
69
|
-
class="mt-4"
|
|
70
167
|
color="error"
|
|
168
|
+
variant="subtle"
|
|
71
169
|
:title="error"
|
|
72
170
|
/>
|
|
171
|
+
|
|
172
|
+
<UModal
|
|
173
|
+
v-model:open="emailModalOpen"
|
|
174
|
+
:title="t('email.change.title', 'Change email')"
|
|
175
|
+
>
|
|
176
|
+
<template #body>
|
|
177
|
+
<UForm
|
|
178
|
+
id="change-email"
|
|
179
|
+
:schema="emailSchema"
|
|
180
|
+
:state="{ email: newEmail }"
|
|
181
|
+
class="flex flex-col gap-4"
|
|
182
|
+
@submit="onSubmitEmail"
|
|
183
|
+
>
|
|
184
|
+
<UFormField
|
|
185
|
+
name="email"
|
|
186
|
+
:label="t('email.change.label', 'New e-mail')"
|
|
187
|
+
required
|
|
188
|
+
>
|
|
189
|
+
<UInput
|
|
190
|
+
v-model="newEmail"
|
|
191
|
+
type="email"
|
|
192
|
+
autocomplete="email"
|
|
193
|
+
class="w-full"
|
|
194
|
+
/>
|
|
195
|
+
</UFormField>
|
|
196
|
+
</UForm>
|
|
197
|
+
</template>
|
|
198
|
+
<template #footer>
|
|
199
|
+
<div class="flex w-full justify-end gap-2">
|
|
200
|
+
<UButton
|
|
201
|
+
color="neutral"
|
|
202
|
+
variant="outline"
|
|
203
|
+
:label="t('cancel', 'Cancel')"
|
|
204
|
+
@click="emailModalOpen = false"
|
|
205
|
+
/>
|
|
206
|
+
<UButton
|
|
207
|
+
type="submit"
|
|
208
|
+
form="change-email"
|
|
209
|
+
:loading="emailPending"
|
|
210
|
+
:label="t('email.change.title', 'Change email')"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
</template>
|
|
214
|
+
</UModal>
|
|
73
215
|
</UPageBody>
|
|
74
216
|
</UPage>
|
|
75
217
|
</UContainer>
|
|
@@ -1,90 +1,210 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
2
3
|
defineOptions({ name: "AccountSecurityPage" });
|
|
3
4
|
const t = useModuleT("account.security");
|
|
5
|
+
const toast = useToast();
|
|
4
6
|
const { user } = useUserSession();
|
|
5
|
-
const { changePassword, pending
|
|
6
|
-
const webAuthnEnabled = useRuntimeConfig().public.
|
|
7
|
-
const passkeys = webAuthnEnabled ?
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
|
|
7
|
+
const { changePassword, pending } = useAccount();
|
|
8
|
+
const webAuthnEnabled = useRuntimeConfig().public.authentication?.webAuthn === true;
|
|
9
|
+
const passkeys = webAuthnEnabled ? usePasskeys() : void 0;
|
|
10
|
+
function errorMessage(e, fallback) {
|
|
11
|
+
const err = e;
|
|
12
|
+
return err?.data?.statusMessage ?? err?.statusMessage ?? fallback;
|
|
13
|
+
}
|
|
14
|
+
const passwordSchema = z.object({
|
|
15
|
+
currentPassword: z.string().min(1, t("changePassword.current.required", "Enter your current password.")),
|
|
16
|
+
newPassword: z.string().min(8, t("changePassword.new.min", "Use at least 8 characters.")),
|
|
17
|
+
confirmNewPassword: z.string()
|
|
18
|
+
}).refine((data) => data.newPassword === data.confirmNewPassword, {
|
|
19
|
+
message: t("changePassword.confirm.mismatch", "The passwords do not match."),
|
|
20
|
+
path: ["confirmNewPassword"]
|
|
21
|
+
});
|
|
22
|
+
const passwordState = reactive({ currentPassword: "", newPassword: "", confirmNewPassword: "" });
|
|
23
|
+
async function onSubmitPassword(event) {
|
|
24
|
+
try {
|
|
25
|
+
await changePassword(event.data);
|
|
26
|
+
passwordState.currentPassword = "";
|
|
27
|
+
passwordState.newPassword = "";
|
|
28
|
+
passwordState.confirmNewPassword = "";
|
|
29
|
+
toast.add({ title: t("changePassword.success", "Your password was changed."), color: "success" });
|
|
30
|
+
} catch (e) {
|
|
31
|
+
toast.add({ title: errorMessage(e, t("changePassword.error", "Could not change your password.")), color: "error" });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const passkeyList = ref([]);
|
|
35
|
+
const passkeyName = ref("");
|
|
11
36
|
const passkeyPending = ref(false);
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
37
|
+
const passkeysLoading = ref(false);
|
|
38
|
+
const removingId = ref(null);
|
|
39
|
+
async function loadPasskeys() {
|
|
40
|
+
if (!passkeys) return;
|
|
41
|
+
passkeysLoading.value = true;
|
|
42
|
+
try {
|
|
43
|
+
passkeyList.value = await passkeys.listPasskeys();
|
|
44
|
+
} catch (e) {
|
|
45
|
+
toast.add({ title: errorMessage(e, t("passkeys.loadError", "Could not load your passkeys.")), color: "error" });
|
|
46
|
+
} finally {
|
|
47
|
+
passkeysLoading.value = false;
|
|
48
|
+
}
|
|
21
49
|
}
|
|
22
50
|
async function onRegisterPasskey() {
|
|
23
51
|
passkeyPending.value = true;
|
|
24
52
|
try {
|
|
25
|
-
await passkeys?.registerPasskey(user.value?.email ?? "");
|
|
53
|
+
await passkeys?.registerPasskey(user.value?.email ?? "", passkeyName.value);
|
|
54
|
+
passkeyName.value = "";
|
|
55
|
+
toast.add({ title: t("passkeys.registered", "Passkey added."), color: "success" });
|
|
56
|
+
await loadPasskeys();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e?.name !== "NotAllowedError") {
|
|
59
|
+
toast.add({ title: errorMessage(e, t("passkeys.registerError", "Could not register the passkey.")), color: "error" });
|
|
60
|
+
}
|
|
26
61
|
} finally {
|
|
27
62
|
passkeyPending.value = false;
|
|
28
63
|
}
|
|
29
64
|
}
|
|
65
|
+
async function onRemovePasskey(id) {
|
|
66
|
+
removingId.value = id;
|
|
67
|
+
try {
|
|
68
|
+
await passkeys?.removePasskey(id);
|
|
69
|
+
toast.add({ title: t("passkeys.removed", "Passkey removed."), color: "success" });
|
|
70
|
+
await loadPasskeys();
|
|
71
|
+
} catch (e) {
|
|
72
|
+
toast.add({ title: errorMessage(e, t("passkeys.removeError", "Could not remove the passkey.")), color: "error" });
|
|
73
|
+
} finally {
|
|
74
|
+
removingId.value = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const dateFormatter = new Intl.DateTimeFormat(void 0, { dateStyle: "medium" });
|
|
78
|
+
function formatDate(value) {
|
|
79
|
+
const date = new Date(value);
|
|
80
|
+
return Number.isNaN(date.getTime()) ? "" : dateFormatter.format(date);
|
|
81
|
+
}
|
|
82
|
+
onMounted(loadPasskeys);
|
|
30
83
|
</script>
|
|
31
84
|
|
|
32
85
|
<template>
|
|
33
86
|
<UContainer>
|
|
34
87
|
<UPage>
|
|
35
88
|
<UPageBody>
|
|
36
|
-
<UPageCard
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
<UFormField
|
|
47
|
-
:label="t('changePassword.new.label', 'New password')"
|
|
48
|
-
name="newPassword"
|
|
49
|
-
>
|
|
50
|
-
<UInput
|
|
51
|
-
v-model="newPassword"
|
|
52
|
-
type="password"
|
|
53
|
-
/>
|
|
54
|
-
</UFormField>
|
|
55
|
-
<UFormField
|
|
56
|
-
:label="t('changePassword.confirm.label', 'Confirm new password')"
|
|
57
|
-
name="confirmNewPassword"
|
|
89
|
+
<UPageCard
|
|
90
|
+
:title="t('password.title', 'Password')"
|
|
91
|
+
:description="t('password.description', 'Change your password.')"
|
|
92
|
+
variant="subtle"
|
|
93
|
+
>
|
|
94
|
+
<UForm
|
|
95
|
+
:schema="passwordSchema"
|
|
96
|
+
:state="passwordState"
|
|
97
|
+
class="space-y-4"
|
|
98
|
+
@submit="onSubmitPassword"
|
|
58
99
|
>
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
100
|
+
<UFormField
|
|
101
|
+
:label="t('changePassword.current.label', 'Current password')"
|
|
102
|
+
name="currentPassword"
|
|
103
|
+
required
|
|
104
|
+
>
|
|
105
|
+
<UInput
|
|
106
|
+
v-model="passwordState.currentPassword"
|
|
107
|
+
type="password"
|
|
108
|
+
autocomplete="current-password"
|
|
109
|
+
class="w-full"
|
|
110
|
+
/>
|
|
111
|
+
</UFormField>
|
|
112
|
+
<UFormField
|
|
113
|
+
:label="t('changePassword.new.label', 'New password')"
|
|
114
|
+
name="newPassword"
|
|
115
|
+
required
|
|
116
|
+
>
|
|
117
|
+
<UInput
|
|
118
|
+
v-model="passwordState.newPassword"
|
|
119
|
+
type="password"
|
|
120
|
+
autocomplete="new-password"
|
|
121
|
+
class="w-full"
|
|
122
|
+
/>
|
|
123
|
+
</UFormField>
|
|
124
|
+
<UFormField
|
|
125
|
+
:label="t('changePassword.confirm.label', 'Confirm new password')"
|
|
126
|
+
name="confirmNewPassword"
|
|
127
|
+
required
|
|
128
|
+
>
|
|
129
|
+
<UInput
|
|
130
|
+
v-model="passwordState.confirmNewPassword"
|
|
131
|
+
type="password"
|
|
132
|
+
autocomplete="new-password"
|
|
133
|
+
class="w-full"
|
|
134
|
+
/>
|
|
135
|
+
</UFormField>
|
|
136
|
+
<UButton
|
|
137
|
+
type="submit"
|
|
138
|
+
:loading="pending"
|
|
139
|
+
:label="t('changePassword.submit', 'Change password')"
|
|
62
140
|
/>
|
|
63
|
-
</
|
|
64
|
-
<UButton
|
|
65
|
-
class="mt-4"
|
|
66
|
-
:loading="pending"
|
|
67
|
-
:label="t('changePassword.submit', 'Change password')"
|
|
68
|
-
@click="onSubmitPassword"
|
|
69
|
-
/>
|
|
141
|
+
</UForm>
|
|
70
142
|
</UPageCard>
|
|
143
|
+
|
|
71
144
|
<UPageCard
|
|
72
145
|
v-if="passkeys?.isEnabled.value"
|
|
73
|
-
class="mt-4"
|
|
74
146
|
:title="t('passkeys.title', 'Passkeys')"
|
|
147
|
+
:description="t('passkeys.description', 'Sign in with your fingerprint, face or device PIN instead of a password.')"
|
|
148
|
+
variant="subtle"
|
|
75
149
|
>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
150
|
+
<ul
|
|
151
|
+
v-if="passkeyList.length"
|
|
152
|
+
class="divide-y divide-default"
|
|
153
|
+
>
|
|
154
|
+
<li
|
|
155
|
+
v-for="passkey in passkeyList"
|
|
156
|
+
:key="passkey.id"
|
|
157
|
+
class="flex items-center justify-between gap-3 py-3"
|
|
158
|
+
>
|
|
159
|
+
<div class="flex min-w-0 items-center gap-3">
|
|
160
|
+
<UIcon
|
|
161
|
+
name="i-lucide-key-round"
|
|
162
|
+
class="size-5 shrink-0 text-muted"
|
|
163
|
+
/>
|
|
164
|
+
<div class="min-w-0">
|
|
165
|
+
<p class="truncate font-medium text-highlighted">
|
|
166
|
+
{{ passkey.name || t("passkeys.unnamed", "Passkey") }}
|
|
167
|
+
</p>
|
|
168
|
+
<p class="text-sm text-muted">
|
|
169
|
+
{{ t("passkeys.created", "Added {date}", { date: formatDate(passkey.createdAt) }) }}
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
<UButton
|
|
174
|
+
color="error"
|
|
175
|
+
variant="ghost"
|
|
176
|
+
icon="i-lucide-trash-2"
|
|
177
|
+
:loading="removingId === passkey.id"
|
|
178
|
+
:aria-label="t('passkeys.remove', 'Remove passkey')"
|
|
179
|
+
@click="onRemovePasskey(passkey.id)"
|
|
180
|
+
/>
|
|
181
|
+
</li>
|
|
182
|
+
</ul>
|
|
183
|
+
<p
|
|
184
|
+
v-else-if="!passkeysLoading"
|
|
185
|
+
class="text-sm text-muted"
|
|
186
|
+
>
|
|
187
|
+
{{ t("passkeys.empty", "You have not added any passkeys yet.") }}
|
|
188
|
+
</p>
|
|
189
|
+
|
|
190
|
+
<form
|
|
191
|
+
class="flex flex-col gap-2 sm:flex-row"
|
|
192
|
+
@submit.prevent="onRegisterPasskey"
|
|
193
|
+
>
|
|
194
|
+
<UInput
|
|
195
|
+
v-model="passkeyName"
|
|
196
|
+
:placeholder="t('passkeys.name.placeholder', 'Name, e.g. MacBook')"
|
|
197
|
+
:aria-label="t('passkeys.name.label', 'Passkey name')"
|
|
198
|
+
maxlength="100"
|
|
199
|
+
class="w-full sm:max-w-xs"
|
|
200
|
+
/>
|
|
201
|
+
<UButton
|
|
202
|
+
type="submit"
|
|
203
|
+
:loading="passkeyPending"
|
|
204
|
+
:label="t('passkeys.register', 'Register a passkey')"
|
|
205
|
+
/>
|
|
206
|
+
</form>
|
|
81
207
|
</UPageCard>
|
|
82
|
-
<UAlert
|
|
83
|
-
v-if="error"
|
|
84
|
-
class="mt-4"
|
|
85
|
-
color="error"
|
|
86
|
-
:title="error"
|
|
87
|
-
/>
|
|
88
208
|
</UPageBody>
|
|
89
209
|
</UPage>
|
|
90
210
|
</UContainer>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { AccountDocument } from '../../../types.js';
|
|
2
|
+
/** Label in `locale`, falling back to `en`, then the first available value. */
|
|
3
|
+
export declare function documentLabel(document: AccountDocument, locale: string): string;
|
|
4
|
+
/** The documents a user with `role` may see: everything without `roles`, otherwise only matching roles. */
|
|
5
|
+
export declare function visibleDocuments(documents: AccountDocument[], role: string | undefined): AccountDocument[];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function documentLabel(document, locale) {
|
|
2
|
+
if (typeof document.label === "string") return document.label;
|
|
3
|
+
return document.label[locale] ?? document.label.en ?? Object.values(document.label)[0] ?? document.id;
|
|
4
|
+
}
|
|
5
|
+
export function visibleDocuments(documents, role) {
|
|
6
|
+
return documents.filter((document) => !document.roles?.length || !!role && document.roles.includes(role));
|
|
7
|
+
}
|