@frigear-nu/account 0.1.2 → 0.2.0

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