@frigear-nu/account 0.1.1 → 0.1.3

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 (40) hide show
  1. package/README.md +21 -9
  2. package/dist/module.d.mts +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +32 -4
  5. package/dist/runtime/app/components/Account/AccountApiTokenSlideover.d.vue.ts +3 -3
  6. package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue +2 -1
  7. package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue.d.ts +3 -3
  8. package/dist/runtime/app/components/Account/AccountChangeEmailSlideover.d.vue.ts +14 -0
  9. package/dist/runtime/app/components/Account/AccountChangeEmailSlideover.vue +54 -0
  10. package/dist/runtime/app/components/Account/AccountChangeEmailSlideover.vue.d.ts +14 -0
  11. package/dist/runtime/app/components/Account/AccountHeader.d.vue.ts +10 -1
  12. package/dist/runtime/app/components/Account/AccountHeader.vue +6 -3
  13. package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +10 -1
  14. package/dist/runtime/app/components/Account/AccountIdentity.vue +13 -0
  15. package/dist/runtime/app/components/Account/AccountMenu.vue +13 -3
  16. package/dist/runtime/app/components/Account/AccountPasskeySlideover.d.vue.ts +10 -0
  17. package/dist/runtime/app/components/Account/AccountPasskeySlideover.vue +60 -0
  18. package/dist/runtime/app/components/Account/AccountPasskeySlideover.vue.d.ts +10 -0
  19. package/dist/runtime/app/composables/useAccount.d.ts +2 -2
  20. package/dist/runtime/app/composables/useAccount.js +4 -3
  21. package/dist/runtime/app/composables/useAccountNav.d.ts +1 -1
  22. package/dist/runtime/app/layouts/account.d.vue.ts +2 -2
  23. package/dist/runtime/app/layouts/account.vue +15 -13
  24. package/dist/runtime/app/layouts/account.vue.d.ts +2 -2
  25. package/dist/runtime/app/middleware/bootstrap.d.ts +2 -0
  26. package/dist/runtime/app/middleware/bootstrap.js +5 -0
  27. package/dist/runtime/app/pages/api-tokens.vue +23 -28
  28. package/dist/runtime/app/pages/documents.vue +22 -7
  29. package/dist/runtime/app/pages/index.vue +3 -6
  30. package/dist/runtime/app/pages/profile.vue +50 -89
  31. package/dist/runtime/app/pages/security.vue +60 -74
  32. package/dist/runtime/app/plugins/bootstrap.client.d.ts +2 -0
  33. package/dist/runtime/app/plugins/bootstrap.client.js +4 -0
  34. package/dist/runtime/app/utils/api-tokens.d.ts +3 -14
  35. package/dist/runtime/i18n/lang/da.json +13 -10
  36. package/dist/runtime/i18n/lang/en.json +13 -10
  37. package/package.json +13 -13
  38. package/dist/runtime/app/components/Account/AccountHeaderRight.vue +0 -18
  39. /package/dist/runtime/app/components/Account/{AccountHeaderRight.d.vue.ts → AccountIdentity.d.vue.ts} +0 -0
  40. /package/dist/runtime/app/components/Account/{AccountHeaderRight.vue.d.ts → AccountIdentity.vue.d.ts} +0 -0
@@ -1,4 +1,5 @@
1
1
  <script setup>
2
+ import { authApi } from "@frigear-nu/auth/api";
2
3
  import AccountApiTokenSlideover from "../components/Account/AccountApiTokenSlideover.vue";
3
4
  defineOptions({ name: "AccountApiTokensPage" });
4
5
  const t = useModuleT("account.apiTokens");
@@ -9,8 +10,8 @@ const format = useFormat();
9
10
  const { copy } = useCopyToClipboard();
10
11
  const overlay = useOverlay();
11
12
  const createSlideover = overlay.create(AccountApiTokenSlideover, { destroyOnClose: true });
12
- const { data: tokens, refresh, status } = await useFetch("/api/auth/tokens", { key: "account-api-tokens", default: () => [] });
13
- const { data: scopes } = await useFetch("/api/auth/scopes", { key: "account-api-token-scopes", default: () => [] });
13
+ const { data: tokens, refresh, status } = await useCachedFetch(authApi.me.tokens, { key: "account-api-tokens", ttl: 6e4 });
14
+ const { data: scopes } = await useCachedFetch(authApi.scopes, { key: "account-api-token-scopes" });
14
15
  const revealed = ref(null);
15
16
  async function onCreate() {
16
17
  const created = await createSlideover.open({ scopes: scopes.value ?? [] });
@@ -29,7 +30,7 @@ async function onRevoke(token) {
29
30
  });
30
31
  if (!confirmed) return;
31
32
  try {
32
- await $fetch(`/api/auth/tokens/${token.id}`, { method: "DELETE" });
33
+ await $fetch(authApi.me.token(token.id), { method: "DELETE" });
33
34
  toast.add({ title: t("revoked", "Token revoked."), description: token.name, color: "success", icon: "i-lucide-check" });
34
35
  await refresh();
35
36
  } catch (e) {
@@ -45,9 +46,7 @@ const columns = computed(() => [
45
46
  { accessorKey: "expiresAt", header: t("list.expiresColumn", "Expires") },
46
47
  { id: "actions", meta: { class: { td: "w-px text-right" } } }
47
48
  ]);
48
- function tokenStatusColor(status2) {
49
- return statusColor(status2, { ok: "success", expired: "warning" });
50
- }
49
+ const tokenStatusColors = { ok: "success", expired: "warning" };
51
50
  const mcpEnabled = useRuntimeConfig().public.users?.mcp === true;
52
51
  const activeConnect = ref("claude");
53
52
  const mcpUrl = `${useRequestURL().origin}/mcp`;
@@ -76,6 +75,16 @@ const activeTab = computed(() => connectTabs.value.find((tab) => tab.value === a
76
75
 
77
76
  <template>
78
77
  <div class="flex flex-col gap-4">
78
+ <AccountHeader>
79
+ <template #actions>
80
+ <UButton
81
+ icon="i-lucide-plus"
82
+ :label="t('create.submit', 'Create API token')"
83
+ @click="onCreate"
84
+ />
85
+ </template>
86
+ </AccountHeader>
87
+
79
88
  <UAlert
80
89
  v-if="revealed"
81
90
  color="success"
@@ -107,25 +116,11 @@ const activeTab = computed(() => connectTabs.value.find((tab) => tab.value === a
107
116
  </UAlert>
108
117
 
109
118
  <TableCard
110
- :loading="status === 'pending' && !tokens?.length"
111
- :refreshing="status === 'pending' && !!tokens?.length"
119
+ :title="t('list.title', 'Your tokens')"
120
+ :description="t('list.description', 'Tokens let scripts and tools such as MCP clients act as you. Revoke one to stop it working immediately.')"
121
+ :loading="status === 'pending' && !tokens"
122
+ :refreshing="status === 'pending' && !!tokens"
112
123
  >
113
- <template #header>
114
- <div class="min-w-0">
115
- <h2 class="text-sm font-semibold text-highlighted">
116
- {{ t("list.title", "Your tokens") }}
117
- </h2>
118
- <p class="text-sm text-muted">
119
- {{ t("list.description", "Tokens let scripts and tools such as MCP clients act as you. Revoke one to stop it working immediately.") }}
120
- </p>
121
- </div>
122
- <UButton
123
- icon="i-lucide-plus"
124
- :label="t('create.submit', 'Create API token')"
125
- @click="onCreate"
126
- />
127
- </template>
128
-
129
124
  <UTable
130
125
  :data="tokens ?? []"
131
126
  :columns="columns"
@@ -137,11 +132,11 @@ const activeTab = computed(() => connectTabs.value.find((tab) => tab.value === a
137
132
  :description="`${row.original.prefix}\u2026${row.original.last4}`"
138
133
  >
139
134
  <template #badges>
140
- <UBadge
141
- :color="tokenStatusColor(row.original.status)"
142
- variant="subtle"
143
- size="sm"
135
+ <StatusBadge
136
+ :status="row.original.status"
144
137
  :label="t(`status.${row.original.status}`, row.original.status)"
138
+ :colors="tokenStatusColors"
139
+ size="sm"
145
140
  />
146
141
  </template>
147
142
  </TableIdentity>
@@ -10,26 +10,41 @@ const documents = computed(() => visibleDocuments(useRuntimeConfig().public.acco
10
10
 
11
11
  <template>
12
12
  <div class="flex flex-col gap-4">
13
- <SectionCard>
14
- <UPageGrid v-if="documents.length">
15
- <UPageCard
13
+ <AccountHeader />
14
+
15
+ <SectionCard flush>
16
+ <ul
17
+ v-if="documents.length"
18
+ class="divide-y divide-default"
19
+ >
20
+ <li
16
21
  v-for="document in documents"
17
22
  :key="document.id"
18
- :title="documentLabel(document, locale)"
19
- :icon="document.icon ?? 'i-lucide-file'"
23
+ class="flex items-center gap-3 px-4 py-3"
20
24
  >
25
+ <UIcon
26
+ :name="document.icon ?? 'i-lucide-file'"
27
+ class="size-5 shrink-0 text-muted"
28
+ />
29
+ <p class="min-w-0 flex-1 truncate text-sm font-medium text-highlighted">
30
+ {{ documentLabel(document, locale) }}
31
+ </p>
21
32
  <UButton
22
33
  download
23
34
  external
24
35
  :to="document.path"
36
+ color="neutral"
37
+ variant="outline"
25
38
  icon="i-lucide-download"
26
39
  :label="t('download', 'Download')"
40
+ :aria-label="t('downloadNamed', 'Download {name}', { name: documentLabel(document, locale) })"
27
41
  />
28
- </UPageCard>
29
- </UPageGrid>
42
+ </li>
43
+ </ul>
30
44
  <UEmpty
31
45
  v-else
32
46
  variant="naked"
47
+ icon="i-lucide-files"
33
48
  :title="t('empty', 'No documents for you yet.')"
34
49
  />
35
50
  </SectionCard>
@@ -1,6 +1,5 @@
1
1
  <script setup>
2
2
  defineOptions({ name: "AccountIndexPage" });
3
- definePageMeta({ heading: false });
4
3
  const t = useModuleT("account");
5
4
  const { user } = useUserSession();
6
5
  const { localize } = useLocaleLink();
@@ -18,10 +17,7 @@ const profile = computed(() => {
18
17
 
19
18
  <template>
20
19
  <div class="flex flex-col gap-4">
21
- <PageHeading
22
- :title="t('overview.title', 'Account')"
23
- :description="t('overview.description', 'Your details and everything that belongs to your account.')"
24
- />
20
+ <AccountHeader />
25
21
 
26
22
  <SectionCard>
27
23
  <div class="flex flex-wrap items-center justify-between gap-4">
@@ -32,7 +28,7 @@ const profile = computed(() => {
32
28
  size="lg"
33
29
  :ui="{ name: 'truncate', description: 'truncate' }"
34
30
  />
35
- <AccountHeaderRight />
31
+ <AccountIdentity />
36
32
  </div>
37
33
  </SectionCard>
38
34
 
@@ -41,6 +37,7 @@ const profile = computed(() => {
41
37
  v-if="widgetArea"
42
38
  name="account.overview"
43
39
  :context="{ user }"
40
+ grid-class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"
44
41
  />
45
42
 
46
43
  <ListCard
@@ -1,14 +1,19 @@
1
1
  <script setup>
2
2
  import { z } from "zod";
3
+ import AccountChangeEmailSlideover from "../components/Account/AccountChangeEmailSlideover.vue";
3
4
  defineOptions({ name: "AccountProfilePage" });
4
5
  const t = useModuleT("account.profile");
5
6
  const toast = useToast();
6
7
  const route = useRoute();
7
8
  const { user } = useUserSession();
8
- const { updateProfileDetails, changeEmailAddress } = useAccount();
9
+ const { updateProfileDetails } = useAccount();
9
10
  const schema = z.object({
10
11
  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()
12
+ // The server's rule: an http(s) URL of at most 2048 characters (empty clears the avatar).
13
+ avatar: z.union([
14
+ z.literal(""),
15
+ z.url({ protocol: /^https?$/, message: t("avatar.invalid", "Enter a valid URL.") }).max(2048, t("avatar.invalid", "Enter a valid URL."))
16
+ ]).optional()
12
17
  });
13
18
  const state = reactive({
14
19
  name: user.value?.name ?? "",
@@ -28,32 +33,32 @@ const dirty = computed(() => state.name !== saved.value.name || state.avatar !==
28
33
  const profileAction = useAction();
29
34
  function onSubmitProfile(event) {
30
35
  return profileAction.run(
31
- () => updateProfileDetails({ name: event.data.name, avatar: event.data.avatar || void 0 }),
36
+ () => updateProfileDetails({ name: event.data.name, avatar: event.data.avatar || null }),
32
37
  { success: t("saved", "Profile updated"), error: t("saveFailed", "Could not update your profile.") }
33
38
  );
34
39
  }
35
- const emailModalOpen = ref(false);
36
- const newEmail = ref("");
37
40
  const emailVerificationSent = ref(false);
38
41
  const emailChanged = computed(() => route.query["email-changed"] === "1");
39
42
  const emailChangeFailed = computed(() => route.query.error === "email-change");
40
- const emailSchema = z.object({ email: z.email(t("email.invalid", "Enter a valid email address.")) });
41
- const emailAction = useAction();
42
- async function onSubmitEmail() {
43
- const result = await emailAction.run(
44
- () => changeEmailAddress({ email: newEmail.value }),
45
- { error: t("email.change.error", "Could not change your email address.") }
46
- );
43
+ const overlay = useOverlay();
44
+ const emailSlideover = overlay.create(AccountChangeEmailSlideover, { destroyOnClose: true });
45
+ async function onChangeEmail() {
46
+ const result = await emailSlideover.open({ email: user.value?.email ?? void 0 });
47
47
  if (!result) return;
48
- emailVerificationSent.value = result.verificationRequired === true;
49
- emailModalOpen.value = false;
50
- newEmail.value = "";
51
- toast.add({ title: result.verificationRequired ? t("email.change.sent.title", "Check your inbox") : t("email.change.done", "Your email address was changed."), color: "success" });
48
+ emailVerificationSent.value = result.verificationRequired;
49
+ toast.add({
50
+ title: result.verificationRequired ? t("email.change.sent.title", "Check your inbox") : t("email.change.done", "Your email address was changed."),
51
+ description: result.verificationRequired ? void 0 : user.value?.email ?? void 0,
52
+ color: "success",
53
+ icon: "i-lucide-check"
54
+ });
52
55
  }
53
56
  </script>
54
57
 
55
58
  <template>
56
59
  <div class="flex flex-col gap-4">
60
+ <AccountHeader />
61
+
57
62
  <UAlert
58
63
  v-if="emailChanged"
59
64
  color="success"
@@ -74,52 +79,51 @@ async function onSubmitEmail() {
74
79
  />
75
80
 
76
81
  <SectionCard
77
- :title="t('title', 'Profile')"
78
- :description="t('description', 'Change your information.')"
82
+ :title="t('details.title', 'Name and avatar')"
83
+ :description="t('details.description', 'How you appear to others.')"
79
84
  >
80
85
  <UForm
81
86
  id="account-profile"
82
87
  :schema="schema"
83
88
  :state="state"
84
- class="flex flex-col gap-4"
89
+ class="flex flex-col divide-y divide-default"
85
90
  @submit="onSubmitProfile"
86
91
  >
87
- <UFormField
92
+ <FormRow
88
93
  name="name"
89
94
  :label="t('name.label', 'Name')"
90
95
  :description="t('name.description', 'Your full name.')"
91
96
  required
92
- class="flex items-start justify-between gap-4 max-sm:flex-col"
93
97
  >
94
98
  <UInput
95
99
  v-model="state.name"
96
100
  autocomplete="name"
101
+ class="w-full sm:max-w-sm"
97
102
  />
98
- </UFormField>
99
- <USeparator />
100
- <UFormField
103
+ </FormRow>
104
+ <FormRow
101
105
  name="avatar"
102
106
  :label="t('avatar.label', 'Avatar')"
103
107
  :description="t('avatar.description', 'Link to a square image.')"
104
- class="flex items-start justify-between gap-4 max-sm:flex-col"
105
108
  >
106
- <div class="flex items-center gap-3">
109
+ <div class="flex items-center gap-3 sm:max-w-sm">
107
110
  <UAvatar
108
111
  :src="state.avatar || void 0"
109
112
  :alt="state.name"
110
- size="xl"
113
+ size="lg"
111
114
  />
112
115
  <UInput
113
116
  v-model="state.avatar"
114
117
  type="url"
115
118
  placeholder="https://"
116
119
  :aria-label="t('avatar.url', 'Avatar URL')"
120
+ class="min-w-0 flex-1"
117
121
  />
118
122
  </div>
119
- </UFormField>
123
+ </FormRow>
120
124
  </UForm>
121
125
  <template #footer>
122
- <div class="flex justify-end">
126
+ <div class="flex justify-end gap-2">
123
127
  <UButton
124
128
  type="submit"
125
129
  form="account-profile"
@@ -131,71 +135,28 @@ async function onSubmitEmail() {
131
135
  </template>
132
136
  </SectionCard>
133
137
 
134
- <SectionCard
135
- :title="t('email.label', 'Email')"
136
- :description="t('email.description', 'Your email address.')"
137
- >
138
- <template #actions>
139
- <UButton
140
- color="neutral"
141
- variant="outline"
142
- icon="i-lucide-pencil"
143
- :label="t('email.change.title', 'Change email')"
144
- @click="emailModalOpen = true"
145
- />
146
- </template>
147
- <UInput
148
- :model-value="user?.email ?? ''"
149
- type="email"
150
- readonly
151
- disabled
152
- class="w-full sm:max-w-xs"
153
- :aria-label="t('email.label', 'Email')"
154
- />
155
- </SectionCard>
156
-
157
- <UModal
158
- v-model:open="emailModalOpen"
159
- :title="t('email.change.title', 'Change email')"
160
- >
161
- <template #body>
162
- <UForm
163
- id="account-change-email"
164
- :schema="emailSchema"
165
- :state="{ email: newEmail }"
166
- class="flex flex-col gap-4"
167
- @submit="onSubmitEmail"
168
- >
169
- <UFormField
170
- name="email"
171
- :label="t('email.change.label', 'New e-mail')"
172
- required
173
- >
174
- <UInput
175
- v-model="newEmail"
176
- type="email"
177
- autocomplete="email"
178
- class="w-full"
179
- />
180
- </UFormField>
181
- </UForm>
182
- </template>
183
- <template #footer>
184
- <div class="flex w-full justify-end gap-2">
138
+ <SectionCard :title="t('email.label', 'Email')">
139
+ <FormRow
140
+ :label="t('email.address', 'Address')"
141
+ :description="t('email.description', 'Where we send messages and how you sign in.')"
142
+ >
143
+ <div class="flex flex-wrap items-center gap-2 sm:max-w-lg">
144
+ <UInput
145
+ :model-value="user?.email ?? ''"
146
+ type="email"
147
+ readonly
148
+ disabled
149
+ class="min-w-48 flex-1"
150
+ />
185
151
  <UButton
186
152
  color="neutral"
187
153
  variant="outline"
188
- :label="t('cancel', 'Cancel')"
189
- @click="emailModalOpen = false"
190
- />
191
- <UButton
192
- type="submit"
193
- form="account-change-email"
194
- :loading="emailAction.busy.value"
154
+ icon="i-lucide-pencil"
195
155
  :label="t('email.change.title', 'Change email')"
156
+ @click="onChangeEmail"
196
157
  />
197
158
  </div>
198
- </template>
199
- </UModal>
159
+ </FormRow>
160
+ </SectionCard>
200
161
  </div>
201
162
  </template>
@@ -1,5 +1,7 @@
1
1
  <script setup>
2
2
  import { z } from "zod";
3
+ import { authApi } from "@frigear-nu/auth/api";
4
+ import AccountPasskeySlideover from "../components/Account/AccountPasskeySlideover.vue";
3
5
  defineOptions({ name: "AccountSecurityPage" });
4
6
  const t = useModuleT("account.security");
5
7
  const toast = useToast();
@@ -33,35 +35,25 @@ async function onSubmitPassword(event) {
33
35
  passwordState.newPassword = "";
34
36
  passwordState.confirmNewPassword = "";
35
37
  }
36
- const passkeyList = ref([]);
37
- const passkeyName = ref("");
38
- const passkeyPending = ref(false);
39
- const passkeysLoading = ref(false);
40
- async function loadPasskeys() {
41
- if (!passkeys) return;
42
- passkeysLoading.value = true;
43
- try {
44
- passkeyList.value = await passkeys.listPasskeys();
45
- } catch (e) {
46
- showError(e, t("passkeys.loadError", "Could not load your passkeys."));
47
- } finally {
48
- passkeysLoading.value = false;
49
- }
50
- }
51
- async function onRegisterPasskey() {
52
- passkeyPending.value = true;
53
- try {
54
- await passkeys?.registerPasskey(user.value?.email ?? "", passkeyName.value);
55
- passkeyName.value = "";
56
- toast.add({ title: t("passkeys.registered", "Passkey added."), color: "success", icon: "i-lucide-check" });
57
- await loadPasskeys();
58
- } catch (e) {
59
- if (e?.name !== "NotAllowedError") {
60
- showError(e, t("passkeys.registerError", "Could not register the passkey."));
61
- }
62
- } finally {
63
- passkeyPending.value = false;
64
- }
38
+ const {
39
+ data: passkeyData,
40
+ error: passkeysError,
41
+ loading: passkeysLoading,
42
+ refresh: loadPasskeys
43
+ } = await useCachedFetch(authApi.me.passkeys, {
44
+ key: () => `account:passkeys:${user.value?.id ?? "anonymous"}`,
45
+ ttl: 0,
46
+ immediate: webAuthnEnabled,
47
+ watch: false
48
+ });
49
+ const passkeyList = computed(() => passkeyData.value ?? []);
50
+ const overlay = useOverlay();
51
+ const passkeySlideover = overlay.create(AccountPasskeySlideover, { destroyOnClose: true });
52
+ async function onAddPasskey() {
53
+ const added = await passkeySlideover.open({ userName: user.value?.email ?? "" });
54
+ if (!added) return;
55
+ toast.add({ title: t("passkeys.registered", "Passkey added."), color: "success", icon: "i-lucide-check" });
56
+ await loadPasskeys();
65
57
  }
66
58
  const passkeyLabel = (passkey) => passkey.name || t("passkeys.unnamed", "Passkey");
67
59
  async function onRemovePasskey(passkey) {
@@ -83,11 +75,12 @@ async function onRemovePasskey(passkey) {
83
75
  const passkeyMenu = (passkey) => [[
84
76
  { label: t("passkeys.remove", "Remove passkey"), icon: "i-lucide-trash-2", color: "error", onSelect: () => onRemovePasskey(passkey) }
85
77
  ]];
86
- onMounted(loadPasskeys);
87
78
  </script>
88
79
 
89
80
  <template>
90
81
  <div class="flex flex-col gap-4">
82
+ <AccountHeader />
83
+
91
84
  <SectionCard
92
85
  :title="t('password.title', 'Password')"
93
86
  :description="t('password.description', 'Change your password.')"
@@ -96,10 +89,10 @@ onMounted(loadPasskeys);
96
89
  id="account-change-password"
97
90
  :schema="passwordSchema"
98
91
  :state="passwordState"
99
- class="flex flex-col gap-4"
92
+ class="flex flex-col divide-y divide-default"
100
93
  @submit="onSubmitPassword"
101
94
  >
102
- <UFormField
95
+ <FormRow
103
96
  :label="t('changePassword.current.label', 'Current password')"
104
97
  name="currentPassword"
105
98
  required
@@ -110,8 +103,8 @@ onMounted(loadPasskeys);
110
103
  autocomplete="current-password"
111
104
  class="w-full sm:max-w-sm"
112
105
  />
113
- </UFormField>
114
- <UFormField
106
+ </FormRow>
107
+ <FormRow
115
108
  :label="t('changePassword.new.label', 'New password')"
116
109
  name="newPassword"
117
110
  required
@@ -122,8 +115,8 @@ onMounted(loadPasskeys);
122
115
  autocomplete="new-password"
123
116
  class="w-full sm:max-w-sm"
124
117
  />
125
- </UFormField>
126
- <UFormField
118
+ </FormRow>
119
+ <FormRow
127
120
  :label="t('changePassword.confirm.label', 'Confirm new password')"
128
121
  name="confirmNewPassword"
129
122
  required
@@ -134,10 +127,10 @@ onMounted(loadPasskeys);
134
127
  autocomplete="new-password"
135
128
  class="w-full sm:max-w-sm"
136
129
  />
137
- </UFormField>
130
+ </FormRow>
138
131
  </UForm>
139
132
  <template #footer>
140
- <div class="flex justify-end">
133
+ <div class="flex justify-end gap-2">
141
134
  <UButton
142
135
  type="submit"
143
136
  form="account-change-password"
@@ -152,9 +145,16 @@ onMounted(loadPasskeys);
152
145
  v-if="passkeys?.isEnabled.value"
153
146
  :title="t('passkeys.title', 'Passkeys')"
154
147
  :description="t('passkeys.description', 'Sign in with your fingerprint, face or device PIN instead of a password.')"
155
- :loading="passkeysLoading && !passkeyList.length"
148
+ :loading="passkeysLoading"
156
149
  flush
157
150
  >
151
+ <template #actions>
152
+ <UButton
153
+ icon="i-lucide-plus"
154
+ :label="t('passkeys.add', 'Add passkey')"
155
+ @click="onAddPasskey"
156
+ />
157
+ </template>
158
158
  <ul
159
159
  v-if="passkeyList.length"
160
160
  class="divide-y divide-default"
@@ -162,21 +162,19 @@ onMounted(loadPasskeys);
162
162
  <li
163
163
  v-for="passkey in passkeyList"
164
164
  :key="passkey.id"
165
- class="flex items-center justify-between gap-3 px-4 py-3"
165
+ class="flex items-center gap-3 px-4 py-3"
166
166
  >
167
- <div class="flex min-w-0 items-center gap-3">
168
- <UIcon
169
- name="i-lucide-key-round"
170
- class="size-5 shrink-0 text-muted"
171
- />
172
- <div class="min-w-0">
173
- <p class="truncate text-sm font-medium text-highlighted">
174
- {{ passkeyLabel(passkey) }}
175
- </p>
176
- <p class="text-sm text-muted">
177
- {{ t("passkeys.created", "Added {date}", { date: format.date(passkey.createdAt) }) }}
178
- </p>
179
- </div>
167
+ <UIcon
168
+ name="i-lucide-key-round"
169
+ class="size-5 shrink-0 text-muted"
170
+ />
171
+ <div class="min-w-0 flex-1">
172
+ <p class="truncate text-sm font-medium text-highlighted">
173
+ {{ passkeyLabel(passkey) }}
174
+ </p>
175
+ <p class="text-sm text-muted">
176
+ {{ t("passkeys.created", "Added {date}", { date: format.date(passkey.createdAt) }) }}
177
+ </p>
180
178
  </div>
181
179
  <RowMenu
182
180
  :items="passkeyMenu(passkey)"
@@ -184,33 +182,21 @@ onMounted(loadPasskeys);
184
182
  />
185
183
  </li>
186
184
  </ul>
185
+ <UEmpty
186
+ v-else-if="passkeysError"
187
+ variant="naked"
188
+ icon="i-lucide-circle-alert"
189
+ :title="t('passkeys.loadError', 'Could not load your passkeys.')"
190
+ :description="errorMessage(passkeysError, '')"
191
+ :actions="[{ label: t('passkeys.tryAgain', 'Try again'), icon: 'i-lucide-rotate-ccw', color: 'neutral', variant: 'outline', onClick: () => loadPasskeys() }]"
192
+ />
187
193
  <UEmpty
188
194
  v-else
189
195
  variant="naked"
190
196
  icon="i-lucide-key-round"
191
197
  :title="t('passkeys.empty', 'You have not added any passkeys yet.')"
198
+ :actions="[{ label: t('passkeys.add', 'Add passkey'), icon: 'i-lucide-plus', onClick: onAddPasskey }]"
192
199
  />
193
-
194
- <template #footer>
195
- <form
196
- class="flex flex-col gap-2 sm:flex-row sm:justify-end"
197
- @submit.prevent="onRegisterPasskey"
198
- >
199
- <UInput
200
- v-model="passkeyName"
201
- :placeholder="t('passkeys.name.placeholder', 'Name, e.g. MacBook')"
202
- :aria-label="t('passkeys.name.label', 'Passkey name')"
203
- maxlength="100"
204
- class="w-full sm:max-w-xs"
205
- />
206
- <UButton
207
- type="submit"
208
- icon="i-lucide-plus"
209
- :loading="passkeyPending"
210
- :label="t('passkeys.register', 'Register a passkey')"
211
- />
212
- </form>
213
- </template>
214
200
  </SectionCard>
215
201
  </div>
216
202
  </template>
@@ -0,0 +1,2 @@
1
+ declare const _default: any;
2
+ export default _default;
@@ -0,0 +1,4 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const { user } = useUserSession();
3
+ watch(() => user.value?.id ?? null, () => resetBootstrap("account"));
4
+ });
@@ -1,19 +1,8 @@
1
- export interface ApiToken {
2
- id: string;
3
- name: string;
4
- prefix: string;
5
- last4: string;
6
- scopes: string[];
7
- expiresAt: string | null;
8
- lastUsedAt: string | null;
9
- createdAt: string;
10
- status: 'ok' | 'revoked' | 'expired';
11
- }
1
+ import type { AuthCreatedTokenDto, AuthTokenDto } from '@frigear-nu/auth/utils';
2
+ export type ApiToken = AuthTokenDto;
12
3
  export interface ApiTokenScope {
13
4
  name: string;
14
5
  description?: string;
15
6
  }
16
7
  /** The response to creating a token: the only time its plaintext `token` exists. */
17
- export type CreatedApiToken = ApiToken & {
18
- token: string;
19
- };
8
+ export type CreatedApiToken = AuthCreatedTokenDto;