@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
package/README.md
CHANGED
|
@@ -10,9 +10,10 @@ A module for the account area (profile, security, documents, API tokens pages) i
|
|
|
10
10
|
- 🔒 Change your password.
|
|
11
11
|
- 🔑 Manage passkeys (via `@frigear-nu/auth`'s `usePasskeys`, when WebAuthn is enabled there).
|
|
12
12
|
- 🧩 Overridable composable (`useAccount`) and opt-in profile/security pages (built with [`@nuxt/ui`](https://ui.nuxt.com)).
|
|
13
|
-
- 🗂️ An `account` layout
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
- 🗂️ An `account` layout: a settings page inside the public site (the site header and footer stay), with a centred
|
|
14
|
+
`max-w-5xl` content column. Wide screens get a left section list with the signed-in user, narrow screens a
|
|
15
|
+
`PageSectionSwitcher`, both built from the `account` menu of `@frigear-nu/menu` (when installed). Plus an overview
|
|
16
|
+
page listing the same entries, and an `account.overview` widget area (when `@frigear-nu/widget` is installed).
|
|
16
17
|
|
|
17
18
|
This module is a UI shell: it owns no tables and no mutation routes. The forms call the self-service routes of
|
|
18
19
|
`@frigear-nu/users` (`/api/users/me/*`) and the token routes of `@frigear-nu/auth`.
|
|
@@ -55,9 +56,11 @@ export default defineNuxtConfig({
|
|
|
55
56
|
|
|
56
57
|
## API routes
|
|
57
58
|
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
- `
|
|
59
|
+
These routes belong to `@frigear-nu/users`; the account pages call them:
|
|
60
|
+
|
|
61
|
+
- `PATCH /api/users/me` - updates `name`/`avatar`.
|
|
62
|
+
- `PUT /api/users/me/email` - updates `email` (rejects if already taken by another user).
|
|
63
|
+
- `PUT /api/users/me/password` - verifies `currentPassword` and updates to `newPassword`.
|
|
61
64
|
|
|
62
65
|
All three require an authenticated session and re-map the updated user into it.
|
|
63
66
|
|
|
@@ -79,6 +82,15 @@ Strings live under the `account.*` namespace in `da`/`en` locale files and can b
|
|
|
79
82
|
## Overriding parts
|
|
80
83
|
|
|
81
84
|
Any `Account*` component can be replaced by a same-named component in your app (the module's have a lower priority).
|
|
82
|
-
`
|
|
83
|
-
override it to show other details
|
|
84
|
-
|
|
85
|
+
`AccountIdentity` is shown next to the user in the overview's summary card (by default a role badge for anyone who is
|
|
86
|
+
not a plain `user`); override it to show other details, such as a member or ID badge. To replace the whole layout,
|
|
87
|
+
define `app/layouts/account.vue` in your app: it wins over the module's, and the account pages still render in it. Set
|
|
88
|
+
`account.settings.layout: false` to remove the layout entirely and render the pages in whatever layout the app picks.
|
|
89
|
+
|
|
90
|
+
## Page headings
|
|
91
|
+
|
|
92
|
+
The layout renders no heading: every page renders its own, as the first child of its root
|
|
93
|
+
`<div class="flex flex-col gap-4">`. The module's pages use `AccountHeader`, a `PageHeading` whose title is the page's
|
|
94
|
+
`title` meta, else the label of the `account` menu item the route belongs to; the description comes from the same
|
|
95
|
+
item, and `#actions` is passed through (the API tokens page puts "Create API token" there). Pages other modules add
|
|
96
|
+
under `/account` render a `PageHeading` with the same i18n keys as their menu item.
|
package/dist/module.d.mts
CHANGED
|
@@ -28,7 +28,9 @@ type ModuleOptions = {
|
|
|
28
28
|
settings?: {
|
|
29
29
|
/**
|
|
30
30
|
* Renders `/account`, `/account/**` and the configured page paths in the `account` layout (the site layout with
|
|
31
|
-
* a left account navigation). `
|
|
31
|
+
* a left account navigation). An `account` layout of the app (`app/layouts/account.vue`) or a layer replaces the
|
|
32
|
+
* module's and the area still renders in it. `false` registers no layout and sets no layout rule: the pages render
|
|
33
|
+
* in whatever layout the app picks. Defaults to `true`.
|
|
32
34
|
*/
|
|
33
35
|
layout?: boolean;
|
|
34
36
|
pages?: {
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { defineNuxtModule, createResolver, hasNuxtModule, addImports,
|
|
2
|
-
import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, addModulePages, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
|
|
1
|
+
import { defineNuxtModule, createResolver, hasNuxtModule, addImports, addTypeTemplate, addRouteMiddleware, addPlugin, addComponentsDir } from '@nuxt/kit';
|
|
2
|
+
import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, addModulePages, defineVirtualRegistry, getBootstrapProviders, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
|
|
3
3
|
|
|
4
4
|
const module$1 = defineNuxtModule({
|
|
5
5
|
meta: { name: "@frigear-nu/account", configKey: "account" },
|
|
@@ -44,8 +44,12 @@ const module$1 = defineNuxtModule({
|
|
|
44
44
|
const profilePath = resolvePagePath(settings.pages?.profile, "/account/profile");
|
|
45
45
|
const securityPath = resolvePagePath(settings.pages?.security, "/account/security");
|
|
46
46
|
const documentsPath = resolvePagePath(settings.pages?.documents, "/account/documents");
|
|
47
|
-
const
|
|
47
|
+
const withApiTokens = nuxt.options.authentication?.apiTokens !== false;
|
|
48
|
+
const apiTokensPath = withApiTokens ? resolvePagePath(settings.pages?.apiTokens, "/account/api-tokens") : void 0;
|
|
48
49
|
const indexPath = withMenus ? resolvePagePath(settings.pages?.index, "/account") : void 0;
|
|
50
|
+
if (!withApiTokens && settings.pages?.apiTokens) {
|
|
51
|
+
console.warn("[@frigear-nu/account] `settings.pages.apiTokens` was set, but `authentication.apiTokens` is `false` - skipping the API tokens page.");
|
|
52
|
+
}
|
|
49
53
|
if (!withMenus && settings.pages?.index) {
|
|
50
54
|
console.warn("[@frigear-nu/account] `settings.pages.index` was set, but `@frigear-nu/menu` is not installed - skipping the dashboard page.");
|
|
51
55
|
}
|
|
@@ -64,7 +68,9 @@ const module$1 = defineNuxtModule({
|
|
|
64
68
|
const outside = [indexPath, profilePath, securityPath, documentsPath, apiTokensPath].filter((path) => !!path && path !== "/account" && !path.startsWith("/account/"));
|
|
65
69
|
const areaPatterns = prefixes.flatMap((prefix) => [`${prefix}/account`, `${prefix}/account/**`, ...outside.map((path) => `${prefix}${path}`)]);
|
|
66
70
|
if (settings.layout !== false) {
|
|
67
|
-
|
|
71
|
+
nuxt.hook("app:resolve", (app) => {
|
|
72
|
+
app.layouts.account ||= { name: "account", file: resolver.resolve("./runtime/app/layouts/account.vue") };
|
|
73
|
+
});
|
|
68
74
|
const rules = nuxt.options.routeRules ||= {};
|
|
69
75
|
for (const pattern of areaPatterns) {
|
|
70
76
|
rules[pattern] = { appLayout: "account", ...rules[pattern] };
|
|
@@ -78,6 +84,28 @@ const module$1 = defineNuxtModule({
|
|
|
78
84
|
} else {
|
|
79
85
|
console.warn("[@frigear-nu/account] @frigear-nu/authorization is not installed: the account pages are not guarded. Install it and guard `/account/**` yourself.");
|
|
80
86
|
}
|
|
87
|
+
defineVirtualRegistry(nuxt, {
|
|
88
|
+
filename: "account/bootstrap.mjs",
|
|
89
|
+
alias: "#account/bootstrap",
|
|
90
|
+
collect: () => getBootstrapProviders(nuxt, "account").map((provider) => provider.key),
|
|
91
|
+
contents: (keys) => `export const keys = ${JSON.stringify(keys)}
|
|
92
|
+
`
|
|
93
|
+
});
|
|
94
|
+
addTypeTemplate({
|
|
95
|
+
filename: "types/account-bootstrap.d.ts",
|
|
96
|
+
getContents: () => `declare module '#account/bootstrap' {
|
|
97
|
+
export const keys: string[]
|
|
98
|
+
}
|
|
99
|
+
export {}
|
|
100
|
+
`
|
|
101
|
+
});
|
|
102
|
+
addRouteMiddleware({ name: "account-bootstrap", path: resolver.resolve("./runtime/app/middleware/bootstrap") });
|
|
103
|
+
addPlugin({ src: resolver.resolve("./runtime/app/plugins/bootstrap.client"), mode: "client" });
|
|
104
|
+
const areaRules = nuxt.options.routeRules ||= {};
|
|
105
|
+
for (const pattern of areaPatterns) {
|
|
106
|
+
const rule = areaRules[pattern] ?? {};
|
|
107
|
+
areaRules[pattern] = { ...rule, appMiddleware: { "account-bootstrap": true, ...rule.appMiddleware } };
|
|
108
|
+
}
|
|
81
109
|
addOptionalMenuSource(nuxt, { file: resolver.resolve("./runtime/menus"), menu: "account" });
|
|
82
110
|
addComponentsDir({
|
|
83
111
|
path: resolver.resolve("./runtime/app/components"),
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { ApiTokenScope
|
|
1
|
+
import type { ApiTokenScope } from '../../utils/api-tokens.js';
|
|
2
2
|
type __VLS_Props = {
|
|
3
3
|
scopes: ApiTokenScope[];
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
6
|
-
close: (created: false |
|
|
6
|
+
close: (created: false | import("@frigear-nu/auth").AuthCreatedTokenDto) => any;
|
|
7
7
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
8
|
-
onClose?: ((created: false |
|
|
8
|
+
onClose?: ((created: false | import("@frigear-nu/auth").AuthCreatedTokenDto) => any) | undefined;
|
|
9
9
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
10
10
|
declare const _default: typeof __VLS_export;
|
|
11
11
|
export default _default;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup>
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { authApi } from "@frigear-nu/auth/api";
|
|
3
4
|
const props = defineProps({
|
|
4
5
|
scopes: { type: Array, required: true }
|
|
5
6
|
});
|
|
@@ -25,7 +26,7 @@ const schema = z.object({
|
|
|
25
26
|
const state = reactive({ name: "", scopes: [], expiresInDays: 90 });
|
|
26
27
|
async function onSubmit(event) {
|
|
27
28
|
const created = await run(
|
|
28
|
-
() => $fetch(
|
|
29
|
+
() => $fetch(authApi.me.tokens, {
|
|
29
30
|
method: "POST",
|
|
30
31
|
body: { name: event.data.name, scopes: event.data.scopes, expiresInDays: event.data.expiresInDays || null }
|
|
31
32
|
}),
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import type { ApiTokenScope
|
|
1
|
+
import type { ApiTokenScope } from '../../utils/api-tokens.js';
|
|
2
2
|
type __VLS_Props = {
|
|
3
3
|
scopes: ApiTokenScope[];
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
6
|
-
close: (created: false |
|
|
6
|
+
close: (created: false | import("@frigear-nu/auth").AuthCreatedTokenDto) => any;
|
|
7
7
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
8
|
-
onClose?: ((created: false |
|
|
8
|
+
onClose?: ((created: false | import("@frigear-nu/auth").AuthCreatedTokenDto) => any) | undefined;
|
|
9
9
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
10
10
|
declare const _default: typeof __VLS_export;
|
|
11
11
|
export default _default;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
email?: string;
|
|
3
|
+
};
|
|
4
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
5
|
+
close: (result: false | {
|
|
6
|
+
verificationRequired: boolean;
|
|
7
|
+
}) => any;
|
|
8
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
9
|
+
onClose?: ((result: false | {
|
|
10
|
+
verificationRequired: boolean;
|
|
11
|
+
}) => any) | undefined;
|
|
12
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const _default: typeof __VLS_export;
|
|
14
|
+
export default _default;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
email: { type: String, required: false }
|
|
5
|
+
});
|
|
6
|
+
const emit = defineEmits(["close"]);
|
|
7
|
+
const t = useModuleT("account.profile");
|
|
8
|
+
const { changeEmailAddress } = useAccount();
|
|
9
|
+
const { busy, run } = useAction();
|
|
10
|
+
const schema = z.object({
|
|
11
|
+
email: z.email(t("email.invalid", "Enter a valid email address."))
|
|
12
|
+
});
|
|
13
|
+
const state = reactive({ email: "" });
|
|
14
|
+
async function onSubmit(event) {
|
|
15
|
+
const result = await run(
|
|
16
|
+
() => changeEmailAddress({ email: event.data.email }),
|
|
17
|
+
{ error: t("email.change.error", "Could not change your email address.") }
|
|
18
|
+
);
|
|
19
|
+
if (!result) return;
|
|
20
|
+
emit("close", { verificationRequired: result.verificationRequired === true });
|
|
21
|
+
}
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<FormSlideover
|
|
26
|
+
form-id="account-change-email"
|
|
27
|
+
:title="t('email.change.title', 'Change email')"
|
|
28
|
+
:description="props.email ? t('email.change.current', 'Your address is now {email}.', { email: props.email }) : void 0"
|
|
29
|
+
:submit-label="t('email.change.title', 'Change email')"
|
|
30
|
+
:loading="busy"
|
|
31
|
+
@close="emit('close', false)"
|
|
32
|
+
>
|
|
33
|
+
<UForm
|
|
34
|
+
id="account-change-email"
|
|
35
|
+
:schema="schema"
|
|
36
|
+
:state="state"
|
|
37
|
+
class="flex flex-col gap-4"
|
|
38
|
+
@submit="onSubmit"
|
|
39
|
+
>
|
|
40
|
+
<UFormField
|
|
41
|
+
name="email"
|
|
42
|
+
:label="t('email.change.label', 'New email')"
|
|
43
|
+
required
|
|
44
|
+
>
|
|
45
|
+
<UInput
|
|
46
|
+
v-model="state.email"
|
|
47
|
+
type="email"
|
|
48
|
+
autocomplete="email"
|
|
49
|
+
class="w-full"
|
|
50
|
+
/>
|
|
51
|
+
</UFormField>
|
|
52
|
+
</UForm>
|
|
53
|
+
</FormSlideover>
|
|
54
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
email?: string;
|
|
3
|
+
};
|
|
4
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
5
|
+
close: (result: false | {
|
|
6
|
+
verificationRequired: boolean;
|
|
7
|
+
}) => any;
|
|
8
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
9
|
+
onClose?: ((result: false | {
|
|
10
|
+
verificationRequired: boolean;
|
|
11
|
+
}) => any) | undefined;
|
|
12
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
13
|
+
declare const _default: typeof __VLS_export;
|
|
14
|
+
export default _default;
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
type __VLS_Slots = {
|
|
2
|
+
actions?: () => unknown;
|
|
3
|
+
};
|
|
4
|
+
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
5
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
2
6
|
declare const _default: typeof __VLS_export;
|
|
3
7
|
export default _default;
|
|
8
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
9
|
+
new (): {
|
|
10
|
+
$slots: S;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup>
|
|
2
|
+
defineSlots();
|
|
2
3
|
const t = useModuleT("account");
|
|
3
4
|
const route = useRoute();
|
|
4
5
|
const { current } = await useAccountNav();
|
|
@@ -12,12 +13,14 @@ useHead({ title });
|
|
|
12
13
|
|
|
13
14
|
<template>
|
|
14
15
|
<PageHeading
|
|
15
|
-
v-if="route.meta.heading !== false"
|
|
16
16
|
:title="title"
|
|
17
17
|
:description="description"
|
|
18
18
|
>
|
|
19
|
-
<template
|
|
20
|
-
|
|
19
|
+
<template
|
|
20
|
+
v-if="$slots.actions"
|
|
21
|
+
#actions
|
|
22
|
+
>
|
|
23
|
+
<slot name="actions" />
|
|
21
24
|
</template>
|
|
22
25
|
</PageHeading>
|
|
23
26
|
</template>
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
type __VLS_Slots = {
|
|
2
|
+
actions?: () => unknown;
|
|
3
|
+
};
|
|
4
|
+
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
5
|
+
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
2
6
|
declare const _default: typeof __VLS_export;
|
|
3
7
|
export default _default;
|
|
8
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
9
|
+
new (): {
|
|
10
|
+
$slots: S;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const { user } = useUserSession();
|
|
3
|
+
const role = computed(() => user.value?.role || void 0);
|
|
4
|
+
const roleLabel = computed(() => role.value && role.value !== "user" ? role.value.charAt(0).toUpperCase() + role.value.slice(1) : void 0);
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<UBadge
|
|
9
|
+
v-if="roleLabel"
|
|
10
|
+
variant="subtle"
|
|
11
|
+
:label="roleLabel"
|
|
12
|
+
/>
|
|
13
|
+
</template>
|
|
@@ -8,6 +8,7 @@ const { user } = useUserSession();
|
|
|
8
8
|
const route = useRoute();
|
|
9
9
|
const colorMode = useColorMode();
|
|
10
10
|
const { localize, strip } = useLocaleLink();
|
|
11
|
+
const showError = useErrorToast();
|
|
11
12
|
const dropdown = typeof useMenu === "function" ? useMenu("account.dropdown").data : ref([]);
|
|
12
13
|
const sessionUser = computed(() => user.value);
|
|
13
14
|
const displayName = computed(() => sessionUser.value?.name || sessionUser.value?.email || String(sessionUser.value?.id ?? t("title", "Account")));
|
|
@@ -19,13 +20,22 @@ const isHere = (to) => {
|
|
|
19
20
|
const current = strip(route.path);
|
|
20
21
|
return current === target || target !== "/" && current.startsWith(`${target}/`);
|
|
21
22
|
};
|
|
22
|
-
const
|
|
23
|
+
const runAction = async (item) => {
|
|
24
|
+
try {
|
|
25
|
+
await $fetch(item.to, { method: "POST" });
|
|
26
|
+
} catch (error) {
|
|
27
|
+
showError(error);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
await navigateTo(localize("/"), { external: true, replace: true });
|
|
31
|
+
};
|
|
32
|
+
const toItem = (item) => item.method && item.to ? { label: item.label, icon: item.icon, onSelect: () => runAction(item) } : {
|
|
23
33
|
label: item.label,
|
|
24
34
|
icon: item.icon,
|
|
25
35
|
to: item.to && !item.external ? localize(item.to) : item.to,
|
|
26
36
|
external: item.external,
|
|
27
37
|
replace: item.external
|
|
28
|
-
}
|
|
38
|
+
};
|
|
29
39
|
const appearance = (label, value, icon) => ({
|
|
30
40
|
label,
|
|
31
41
|
icon,
|
|
@@ -39,7 +49,7 @@ const items = computed(() => {
|
|
|
39
49
|
const visible = (dropdown.value ?? []).filter((item) => {
|
|
40
50
|
const roles = item.meta?.roles;
|
|
41
51
|
if (roles?.length && !(role.value && roles.includes(role.value))) return false;
|
|
42
|
-
return !item.to || item.external || !isHere(item.to);
|
|
52
|
+
return !item.to || item.external || item.method || !isHere(item.to);
|
|
43
53
|
});
|
|
44
54
|
const last = (item) => (item.order ?? 0) >= 99;
|
|
45
55
|
return [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
userName: string;
|
|
3
|
+
};
|
|
4
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
5
|
+
close: (added: boolean) => any;
|
|
6
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
7
|
+
onClose?: ((added: boolean) => any) | undefined;
|
|
8
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
9
|
+
declare const _default: typeof __VLS_export;
|
|
10
|
+
export default _default;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const props = defineProps({
|
|
4
|
+
userName: { type: String, required: true }
|
|
5
|
+
});
|
|
6
|
+
const emit = defineEmits(["close"]);
|
|
7
|
+
const t = useModuleT("account.security.passkeys");
|
|
8
|
+
const showError = useErrorToast();
|
|
9
|
+
const passkeys = usePasskeys();
|
|
10
|
+
const busy = ref(false);
|
|
11
|
+
const schema = z.object({
|
|
12
|
+
name: z.string().trim().max(100)
|
|
13
|
+
});
|
|
14
|
+
const state = reactive({ name: "" });
|
|
15
|
+
async function onSubmit(event) {
|
|
16
|
+
busy.value = true;
|
|
17
|
+
try {
|
|
18
|
+
await passkeys.registerPasskey(props.userName, event.data.name);
|
|
19
|
+
emit("close", true);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (e?.name !== "NotAllowedError") {
|
|
22
|
+
showError(e, t("registerError", "Could not register the passkey."));
|
|
23
|
+
}
|
|
24
|
+
} finally {
|
|
25
|
+
busy.value = false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<FormSlideover
|
|
32
|
+
form-id="account-passkey"
|
|
33
|
+
:title="t('add', 'Add passkey')"
|
|
34
|
+
:description="t('description', 'Sign in with your fingerprint, face or device PIN instead of a password.')"
|
|
35
|
+
:submit-label="t('add', 'Add passkey')"
|
|
36
|
+
:loading="busy"
|
|
37
|
+
@close="emit('close', false)"
|
|
38
|
+
>
|
|
39
|
+
<UForm
|
|
40
|
+
id="account-passkey"
|
|
41
|
+
:schema="schema"
|
|
42
|
+
:state="state"
|
|
43
|
+
class="flex flex-col gap-4"
|
|
44
|
+
@submit="onSubmit"
|
|
45
|
+
>
|
|
46
|
+
<UFormField
|
|
47
|
+
name="name"
|
|
48
|
+
:label="t('name.label', 'Passkey name')"
|
|
49
|
+
:description="t('name.description', 'Optional. Helps you tell your passkeys apart.')"
|
|
50
|
+
>
|
|
51
|
+
<UInput
|
|
52
|
+
v-model="state.name"
|
|
53
|
+
:placeholder="t('name.placeholder', 'Name, e.g. MacBook')"
|
|
54
|
+
maxlength="100"
|
|
55
|
+
class="w-full"
|
|
56
|
+
/>
|
|
57
|
+
</UFormField>
|
|
58
|
+
</UForm>
|
|
59
|
+
</FormSlideover>
|
|
60
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
userName: string;
|
|
3
|
+
};
|
|
4
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
5
|
+
close: (added: boolean) => any;
|
|
6
|
+
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
7
|
+
onClose?: ((added: boolean) => any) | undefined;
|
|
8
|
+
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
9
|
+
declare const _default: typeof __VLS_export;
|
|
10
|
+
export default _default;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { usersApi } from "@frigear-nu/users/api";
|
|
1
2
|
export const useAccount = () => {
|
|
2
3
|
const pending = ref(false);
|
|
3
4
|
const error = ref(null);
|
|
@@ -17,19 +18,19 @@ export const useAccount = () => {
|
|
|
17
18
|
}
|
|
18
19
|
function updateProfileDetails(payload) {
|
|
19
20
|
return withRequest(
|
|
20
|
-
() => $fetch(
|
|
21
|
+
() => $fetch(usersApi.me.profile, { method: "PATCH", body: payload }),
|
|
21
22
|
"Failed to update your profile."
|
|
22
23
|
);
|
|
23
24
|
}
|
|
24
25
|
function changeEmailAddress(payload) {
|
|
25
26
|
return withRequest(
|
|
26
|
-
() => $fetch(
|
|
27
|
+
() => $fetch(usersApi.me.email, { method: "PUT", body: payload }),
|
|
27
28
|
"Failed to change your email address."
|
|
28
29
|
);
|
|
29
30
|
}
|
|
30
31
|
function changePassword(payload) {
|
|
31
32
|
return withRequest(
|
|
32
|
-
() => $fetch(
|
|
33
|
+
() => $fetch(usersApi.me.password, { method: "PUT", body: payload }),
|
|
33
34
|
"Failed to change your password."
|
|
34
35
|
);
|
|
35
36
|
}
|
|
@@ -10,7 +10,7 @@ export interface AccountNavItem {
|
|
|
10
10
|
/**
|
|
11
11
|
* The account navigation: the items of the `account` menu (so pages other modules add show up too), or, without
|
|
12
12
|
* `@frigear-nu/menu`, the account pages enabled in `account.settings.pages`. `links` are ready for
|
|
13
|
-
* `UNavigationMenu`/`
|
|
13
|
+
* `UNavigationMenu`/`PageSectionSwitcher` (localised, `active` set, the overview only active on its own path) and `current` is
|
|
14
14
|
* the item the route belongs to. Await it, so the menu is resolved before the page renders on the server.
|
|
15
15
|
*/
|
|
16
16
|
export declare function useAccountNav(): Promise<{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
declare var
|
|
1
|
+
declare var __VLS_29: {};
|
|
2
2
|
type __VLS_Slots = {} & {
|
|
3
|
-
default?: (props: typeof
|
|
3
|
+
default?: (props: typeof __VLS_29) => any;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
6
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
@@ -3,7 +3,7 @@ defineOptions({ name: "AccountLayout" });
|
|
|
3
3
|
const siteLayout = "default";
|
|
4
4
|
const t = useModuleT("account");
|
|
5
5
|
const { user } = useUserSession();
|
|
6
|
-
const { links } = await useAccountNav();
|
|
6
|
+
const { links, current } = await useAccountNav();
|
|
7
7
|
const profile = computed(() => {
|
|
8
8
|
const value = user.value;
|
|
9
9
|
const name = value?.name || value?.email || t("title", "Account");
|
|
@@ -13,20 +13,22 @@ const profile = computed(() => {
|
|
|
13
13
|
avatar: { src: value?.avatar || void 0, alt: name }
|
|
14
14
|
};
|
|
15
15
|
});
|
|
16
|
+
useHead({ title: () => current.value?.label ?? t("title", "Account") }, { tagPriority: "low" });
|
|
16
17
|
</script>
|
|
17
18
|
|
|
18
19
|
<template>
|
|
19
20
|
<NuxtLayout :name="siteLayout">
|
|
20
21
|
<UContainer>
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
<
|
|
22
|
+
<div class="mx-auto w-full max-w-5xl py-8 lg:grid lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-10 lg:py-10">
|
|
23
|
+
<aside class="hidden lg:block">
|
|
24
|
+
<div class="sticky top-[calc(var(--ui-header-height,4rem)+2.5rem)] flex flex-col gap-4">
|
|
24
25
|
<UUser
|
|
25
26
|
v-if="user"
|
|
26
27
|
:name="profile.name"
|
|
27
28
|
:description="profile.email"
|
|
28
29
|
:avatar="profile.avatar"
|
|
29
|
-
|
|
30
|
+
size="sm"
|
|
31
|
+
class="px-2.5"
|
|
30
32
|
:ui="{ name: 'truncate', description: 'truncate' }"
|
|
31
33
|
/>
|
|
32
34
|
<UNavigationMenu
|
|
@@ -35,20 +37,20 @@ const profile = computed(() => {
|
|
|
35
37
|
highlight
|
|
36
38
|
:aria-label="t('nav.label', 'Account navigation')"
|
|
37
39
|
/>
|
|
38
|
-
</
|
|
39
|
-
</
|
|
40
|
+
</div>
|
|
41
|
+
</aside>
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
<!-- `self-start`: a stretched grid item has a definite height, which `h-full` cards inside a page would fill. -->
|
|
44
|
+
<div class="flex min-w-0 flex-col gap-4 lg:self-start">
|
|
45
|
+
<PageSectionSwitcher
|
|
43
46
|
v-if="links.length"
|
|
44
|
-
class="lg:hidden"
|
|
47
|
+
class="mb-2 lg:hidden"
|
|
45
48
|
:items="links"
|
|
46
|
-
:
|
|
49
|
+
:label="t('nav.label', 'Account navigation')"
|
|
47
50
|
/>
|
|
48
|
-
<AccountHeader />
|
|
49
51
|
<slot />
|
|
50
52
|
</div>
|
|
51
|
-
</
|
|
53
|
+
</div>
|
|
52
54
|
</UContainer>
|
|
53
55
|
</NuxtLayout>
|
|
54
56
|
</template>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
declare var
|
|
1
|
+
declare var __VLS_29: {};
|
|
2
2
|
type __VLS_Slots = {} & {
|
|
3
|
-
default?: (props: typeof
|
|
3
|
+
default?: (props: typeof __VLS_29) => any;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
6
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|