@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.
- package/README.md +21 -9
- package/dist/module.d.mts +3 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +32 -4
- package/dist/runtime/app/components/Account/AccountApiTokenSlideover.d.vue.ts +3 -3
- package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue +2 -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 +54 -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 +6 -3
- package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +10 -1
- package/dist/runtime/app/components/Account/AccountIdentity.vue +13 -0
- package/dist/runtime/app/components/Account/AccountMenu.vue +13 -3
- package/dist/runtime/app/components/Account/AccountPasskeySlideover.d.vue.ts +10 -0
- package/dist/runtime/app/components/Account/AccountPasskeySlideover.vue +60 -0
- package/dist/runtime/app/components/Account/AccountPasskeySlideover.vue.d.ts +10 -0
- package/dist/runtime/app/composables/useAccount.d.ts +2 -2
- package/dist/runtime/app/composables/useAccount.js +4 -3
- package/dist/runtime/app/composables/useAccountNav.d.ts +1 -1
- package/dist/runtime/app/layouts/account.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/account.vue +15 -13
- package/dist/runtime/app/layouts/account.vue.d.ts +2 -2
- package/dist/runtime/app/middleware/bootstrap.d.ts +2 -0
- package/dist/runtime/app/middleware/bootstrap.js +5 -0
- package/dist/runtime/app/pages/api-tokens.vue +23 -28
- package/dist/runtime/app/pages/documents.vue +22 -7
- package/dist/runtime/app/pages/index.vue +3 -6
- package/dist/runtime/app/pages/profile.vue +50 -89
- package/dist/runtime/app/pages/security.vue +60 -74
- package/dist/runtime/app/plugins/bootstrap.client.d.ts +2 -0
- package/dist/runtime/app/plugins/bootstrap.client.js +4 -0
- package/dist/runtime/app/utils/api-tokens.d.ts +3 -14
- package/dist/runtime/i18n/lang/da.json +13 -10
- package/dist/runtime/i18n/lang/en.json +13 -10
- package/package.json +13 -13
- 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,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
|
|
13
|
-
const { data: scopes } = await
|
|
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(
|
|
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
|
-
|
|
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
|
-
:
|
|
111
|
-
:
|
|
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
|
-
<
|
|
141
|
-
:
|
|
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
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
29
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|
-
);
|
|
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
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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', '
|
|
78
|
-
:description="t('description', '
|
|
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
|
|
89
|
+
class="flex flex-col divide-y divide-default"
|
|
85
90
|
@submit="onSubmitProfile"
|
|
86
91
|
>
|
|
87
|
-
<
|
|
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
|
-
</
|
|
99
|
-
<
|
|
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="
|
|
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
|
-
</
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
-
</
|
|
199
|
-
</
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
92
|
+
class="flex flex-col divide-y divide-default"
|
|
100
93
|
@submit="onSubmitPassword"
|
|
101
94
|
>
|
|
102
|
-
<
|
|
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
|
-
</
|
|
114
|
-
<
|
|
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
|
-
</
|
|
126
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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
|
|
165
|
+
class="flex items-center gap-3 px-4 py-3"
|
|
166
166
|
>
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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>
|
|
@@ -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;
|