@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.
- package/README.md +21 -9
- package/dist/module.d.mts +3 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +12 -3
- package/dist/runtime/app/components/Account/AccountApiTokenSlideover.d.vue.ts +3 -3
- package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue +3 -1
- package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue.d.ts +3 -3
- package/dist/runtime/app/components/Account/AccountChangeEmailSlideover.d.vue.ts +14 -0
- package/dist/runtime/app/components/Account/AccountChangeEmailSlideover.vue +55 -0
- package/dist/runtime/app/components/Account/AccountChangeEmailSlideover.vue.d.ts +14 -0
- package/dist/runtime/app/components/Account/AccountHeader.d.vue.ts +10 -1
- package/dist/runtime/app/components/Account/AccountHeader.vue +7 -3
- package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +10 -1
- package/dist/runtime/app/components/Account/AccountIdentity.vue +14 -0
- package/dist/runtime/app/components/Account/AccountMenu.vue +15 -3
- package/dist/runtime/app/components/Account/AccountPasskeySlideover.d.vue.ts +10 -0
- package/dist/runtime/app/components/Account/AccountPasskeySlideover.vue +61 -0
- package/dist/runtime/app/components/Account/AccountPasskeySlideover.vue.d.ts +10 -0
- package/dist/runtime/app/composables/useAccount.d.ts +10 -7
- package/dist/runtime/app/composables/useAccount.js +5 -3
- package/dist/runtime/app/composables/useAccountNav.d.ts +2 -2
- package/dist/runtime/app/composables/useAccountNav.js +2 -0
- package/dist/runtime/app/layouts/account.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/account.vue +14 -11
- package/dist/runtime/app/layouts/account.vue.d.ts +2 -2
- package/dist/runtime/app/middleware/bootstrap.d.ts +1 -1
- package/dist/runtime/app/middleware/bootstrap.js +1 -0
- package/dist/runtime/app/pages/api-tokens.vue +24 -28
- package/dist/runtime/app/pages/documents.vue +23 -7
- package/dist/runtime/app/pages/index.vue +4 -6
- package/dist/runtime/app/pages/profile.vue +51 -89
- package/dist/runtime/app/pages/security.vue +41 -60
- package/dist/runtime/app/plugins/bootstrap.client.d.ts +1 -1
- package/dist/runtime/app/plugins/bootstrap.client.js +1 -0
- package/dist/runtime/app/utils/api-tokens.d.ts +3 -14
- package/dist/runtime/i18n/lang/da.json +11 -9
- package/dist/runtime/i18n/lang/en.json +11 -9
- package/dist/runtime/menus.js +1 -0
- package/package.json +11 -11
- package/dist/runtime/app/components/Account/AccountHeaderRight.vue +0 -18
- /package/dist/runtime/app/components/Account/{AccountHeaderRight.d.vue.ts → AccountIdentity.d.vue.ts} +0 -0
- /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(
|
|
13
|
-
const { data: scopes } = await useCachedFetch(
|
|
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(
|
|
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
|
-
|
|
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
|
-
:
|
|
111
|
-
:
|
|
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
|
-
<
|
|
141
|
-
:
|
|
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
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
29
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
41
|
-
const
|
|
42
|
-
async function
|
|
43
|
-
const result = await
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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', '
|
|
78
|
-
:description="t('description', '
|
|
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
|
|
90
|
+
class="flex flex-col divide-y divide-default"
|
|
85
91
|
@submit="onSubmitProfile"
|
|
86
92
|
>
|
|
87
|
-
<
|
|
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
|
-
</
|
|
99
|
-
<
|
|
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="
|
|
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
|
-
</
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
199
|
-
</
|
|
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(
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
93
|
+
class="flex flex-col divide-y divide-default"
|
|
98
94
|
@submit="onSubmitPassword"
|
|
99
95
|
>
|
|
100
|
-
<
|
|
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
|
-
</
|
|
112
|
-
<
|
|
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
|
-
</
|
|
124
|
-
<
|
|
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
|
-
</
|
|
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
|
|
166
|
+
class="flex items-center gap-3 px-4 py-3"
|
|
164
167
|
>
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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:
|
|
1
|
+
declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
|
|
2
2
|
export default _default;
|
|
@@ -1,19 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 =
|
|
18
|
-
token: string;
|
|
19
|
-
};
|
|
8
|
+
export type CreatedApiToken = AuthCreatedTokenDto;
|