@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.
Files changed (37) hide show
  1. package/README.md +11 -6
  2. package/dist/module.d.mts +20 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +35 -40
  5. package/dist/runtime/app/components/Account/AccountHeader.d.vue.ts +3 -0
  6. package/dist/runtime/app/components/Account/AccountHeader.vue +43 -0
  7. package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +3 -0
  8. package/dist/runtime/app/components/Account/AccountHeaderRight.d.vue.ts +3 -0
  9. package/dist/runtime/app/components/Account/AccountHeaderRight.vue +18 -0
  10. package/dist/runtime/app/components/Account/AccountHeaderRight.vue.d.ts +3 -0
  11. package/dist/runtime/app/components/Account/AccountMenu.vue +7 -2
  12. package/dist/runtime/app/composables/useAccount.js +3 -3
  13. package/dist/runtime/app/pages/api-tokens.d.vue.ts +3 -0
  14. package/dist/runtime/app/pages/api-tokens.vue +308 -0
  15. package/dist/runtime/app/pages/api-tokens.vue.d.ts +3 -0
  16. package/dist/runtime/app/pages/documents.d.vue.ts +3 -0
  17. package/dist/runtime/app/pages/documents.vue +38 -0
  18. package/dist/runtime/app/pages/documents.vue.d.ts +3 -0
  19. package/dist/runtime/app/pages/layout.d.vue.ts +3 -0
  20. package/dist/runtime/app/pages/layout.vue +10 -0
  21. package/dist/runtime/app/pages/layout.vue.d.ts +3 -0
  22. package/dist/runtime/app/pages/profile.vue +188 -46
  23. package/dist/runtime/app/pages/security.vue +180 -60
  24. package/dist/runtime/app/utils/documents.d.ts +5 -0
  25. package/dist/runtime/app/utils/documents.js +7 -0
  26. package/dist/runtime/i18n/lang/da.json +120 -16
  27. package/dist/runtime/i18n/lang/en.json +120 -16
  28. package/dist/runtime/menus.js +24 -0
  29. package/package.json +20 -10
  30. package/dist/runtime/server/db/schema.sqlite.d.ts +0 -24
  31. package/dist/runtime/server/db/schema.sqlite.js +0 -4
  32. package/dist/runtime/server/routes/account/change-email.post.d.ts +0 -2
  33. package/dist/runtime/server/routes/account/change-email.post.js +0 -20
  34. package/dist/runtime/server/routes/account/change-password.post.d.ts +0 -2
  35. package/dist/runtime/server/routes/account/change-password.post.js +0 -26
  36. package/dist/runtime/server/routes/account/profile.post.d.ts +0 -2
  37. 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,10 @@
1
+ <script setup>
2
+ defineOptions({ name: "AccountLayoutPage" });
3
+ </script>
4
+
5
+ <template>
6
+ <div>
7
+ <AccountHeader />
8
+ <NuxtPage />
9
+ </div>
10
+ </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;
@@ -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, pending, error } = useAccount();
6
- const name = ref(user.value?.name ?? "");
7
- const avatar = ref(user.value?.avatar ?? void 0);
8
- const email = ref(user.value?.email ?? "");
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
- async function onSubmitProfile() {
16
- await updateProfileDetails({ name: name.value, avatar: avatar.value });
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
- await changeEmailAddress({ email: email.value });
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
- <UPageCard :title="t('title', 'Profile')">
28
- <UFormField
29
- :label="t('name.label', 'Name')"
30
- name="name"
31
- >
32
- <UInput v-model="name" />
33
- </UFormField>
34
- <UFormField
35
- :label="t('avatar.url', 'Avatar URL')"
36
- name="avatar"
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
- <UInput v-model="avatar" />
39
- </UFormField>
40
- <UButton
41
- class="mt-4"
42
- :loading="pending"
43
- :label="t('save', 'Save')"
44
- @click="onSubmitProfile"
45
- />
46
- </UPageCard>
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
- class="mt-4"
49
- :title="t('email.label', 'Email address')"
143
+ variant="subtle"
144
+ :title="t('email.label', 'Email')"
145
+ :description="t('email.description', 'Your email address.')"
50
146
  >
51
- <UFormField
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
- v-model="email"
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
- </UFormField>
60
- <UButton
61
- class="mt-4"
62
- :loading="pending"
63
- :label="t('email.change.title', 'Change email')"
64
- @click="onSubmitEmail"
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, error } = useAccount();
6
- const webAuthnEnabled = useRuntimeConfig().public.users?.webAuthn === true;
7
- const passkeys = webAuthnEnabled ? useUserPasskeys() : void 0;
8
- const currentPassword = ref("");
9
- const newPassword = ref("");
10
- const confirmNewPassword = ref("");
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
- async function onSubmitPassword() {
13
- await changePassword({
14
- currentPassword: currentPassword.value,
15
- newPassword: newPassword.value,
16
- confirmNewPassword: confirmNewPassword.value
17
- });
18
- currentPassword.value = "";
19
- newPassword.value = "";
20
- confirmNewPassword.value = "";
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 :title="t('changePassword.title', 'Change password')">
37
- <UFormField
38
- :label="t('changePassword.current.label', 'Current password')"
39
- name="currentPassword"
40
- >
41
- <UInput
42
- v-model="currentPassword"
43
- type="password"
44
- />
45
- </UFormField>
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
- <UInput
60
- v-model="confirmNewPassword"
61
- type="password"
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
- </UFormField>
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
- <UButton
77
- :loading="passkeyPending"
78
- :label="t('passkeys.register', 'Register a passkey')"
79
- @click="onRegisterPasskey"
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
+ }