@frigear-nu/account 0.0.9 → 0.1.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 (33) hide show
  1. package/README.md +7 -4
  2. package/dist/module.d.mts +5 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +32 -26
  5. package/dist/runtime/app/components/Account/AccountApiTokenSlideover.d.vue.ts +11 -0
  6. package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue +95 -0
  7. package/dist/runtime/app/components/Account/AccountApiTokenSlideover.vue.d.ts +11 -0
  8. package/dist/runtime/app/components/Account/AccountHeader.vue +14 -34
  9. package/dist/runtime/app/components/Account/AccountMenu.d.vue.ts +10 -1
  10. package/dist/runtime/app/components/Account/AccountMenu.vue +100 -51
  11. package/dist/runtime/app/components/Account/AccountMenu.vue.d.ts +10 -1
  12. package/dist/runtime/app/composables/index.d.ts +2 -0
  13. package/dist/runtime/app/composables/index.js +1 -0
  14. package/dist/runtime/app/composables/useAccountNav.d.ts +20 -0
  15. package/dist/runtime/app/composables/useAccountNav.js +18 -0
  16. package/dist/runtime/app/layouts/account.d.vue.ts +13 -0
  17. package/dist/runtime/app/layouts/account.vue +54 -0
  18. package/dist/runtime/app/layouts/account.vue.d.ts +13 -0
  19. package/dist/runtime/app/pages/api-tokens.vue +193 -261
  20. package/dist/runtime/app/pages/documents.vue +25 -26
  21. package/dist/runtime/app/pages/index.vue +73 -17
  22. package/dist/runtime/app/pages/profile.vue +152 -169
  23. package/dist/runtime/app/pages/security.vue +156 -151
  24. package/dist/runtime/app/utils/api-tokens.d.ts +19 -0
  25. package/dist/runtime/app/utils/api-tokens.js +0 -0
  26. package/dist/runtime/i18n/lang/da.json +41 -9
  27. package/dist/runtime/i18n/lang/en.json +41 -9
  28. package/dist/runtime/menus.d.ts +1 -1
  29. package/dist/runtime/menus.js +10 -0
  30. package/package.json +21 -15
  31. package/dist/runtime/app/pages/layout.d.vue.ts +0 -3
  32. package/dist/runtime/app/pages/layout.vue +0 -10
  33. package/dist/runtime/app/pages/layout.vue.d.ts +0 -3
package/README.md CHANGED
@@ -10,8 +10,9 @@ 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
- - 🗂️ Contributes "Profile"/"Security" tiles to `@frigear-nu/menu` (when installed), and an opt-in dashboard page
14
- listing them.
13
+ - 🗂️ An `account` layout (inside the site layout) with a left account navigation built from the `account` menu
14
+ of `@frigear-nu/menu` (when installed), an overview page listing the same entries, and an `account.overview` widget
15
+ area (when `@frigear-nu/widget` is installed).
15
16
 
16
17
  This module is a UI shell: it owns no tables and no mutation routes. The forms call the self-service routes of
17
18
  `@frigear-nu/users` (`/api/users/me/*`) and the token routes of `@frigear-nu/auth`.
@@ -36,7 +37,7 @@ export default defineNuxtConfig({
36
37
  modules: [
37
38
  '@nuxthub/core',
38
39
  '@frigear-nu/users',
39
- '@frigear-nu/menu', // optional - enables the dashboard tiles/page
40
+ '@frigear-nu/menu', // optional - enables the account navigation and overview page
40
41
  '@frigear-nu/account',
41
42
  ],
42
43
  account: {
@@ -78,4 +79,6 @@ Strings live under the `account.*` namespace in `da`/`en` locale files and can b
78
79
  ## Overriding parts
79
80
 
80
81
  Any `Account*` component can be replaced by a same-named component in your app (the module's have a lower priority).
81
- `AccountHeaderRight` is the block at the right of the account header; override it to show other details.
82
+ `AccountHeaderRight` is the actions block of the account page heading (and of the overview's summary card);
83
+ override it to show other details. Set `account.layout: false` to opt out of the account layout and render the
84
+ pages in your own.
package/dist/module.d.mts CHANGED
@@ -26,6 +26,11 @@ type PublicModuleOptions = {
26
26
  };
27
27
  type ModuleOptions = {
28
28
  settings?: {
29
+ /**
30
+ * Renders `/account`, `/account/**` and the configured page paths in the `account` layout (the site layout with
31
+ * a left account navigation). `false` leaves the layout to the host. Defaults to `true`.
32
+ */
33
+ layout?: boolean;
29
34
  pages?: {
30
35
  index?: string | boolean;
31
36
  profile?: string | boolean;
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@frigear-nu/account",
3
3
  "configKey": "account",
4
- "version": "0.0.9",
4
+ "version": "0.1.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { defineNuxtModule, createResolver, hasNuxtModule, addImports, extendPages, addComponentsDir } from '@nuxt/kit';
2
- import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
1
+ import { defineNuxtModule, createResolver, hasNuxtModule, addImports, addLayout, addComponentsDir } from '@nuxt/kit';
2
+ import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, addModulePages, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
3
3
 
4
4
  const module$1 = defineNuxtModule({
5
5
  meta: { name: "@frigear-nu/account", configKey: "account" },
@@ -18,9 +18,16 @@ const module$1 = defineNuxtModule({
18
18
  "@frigear-nu/users": {
19
19
  version: ">=0.0.1"
20
20
  },
21
+ // Page pattern components (`PageHeading`, `SectionCard`, ...), `useNavLinks` and the error/format helpers.
22
+ "@frigear-nu/ui": { version: ">=0.0.0" },
21
23
  "@frigear-nu/menu": {
22
24
  version: ">=0.0.1",
23
25
  optional: true
26
+ },
27
+ // The overview renders the `account.overview` widget area when it is installed.
28
+ "@frigear-nu/widget": {
29
+ version: ">=0.0.0",
30
+ optional: true
24
31
  }
25
32
  },
26
33
  async setup(options, nuxt) {
@@ -30,7 +37,10 @@ const module$1 = defineNuxtModule({
30
37
  registerLocales(nuxt, resolver.resolve("./runtime/i18n/lang"));
31
38
  const settings = options.settings ?? {};
32
39
  const withMenus = hasNuxtModule("@frigear-nu/menu");
33
- addImports([{ name: "useAccount", from: resolver.resolve("./runtime/app/composables/useAccount") }]);
40
+ addImports([
41
+ { name: "useAccount", from: resolver.resolve("./runtime/app/composables/useAccount") },
42
+ { name: "useAccountNav", from: resolver.resolve("./runtime/app/composables/useAccountNav") }
43
+ ]);
34
44
  const profilePath = resolvePagePath(settings.pages?.profile, "/account/profile");
35
45
  const securityPath = resolvePagePath(settings.pages?.security, "/account/security");
36
46
  const documentsPath = resolvePagePath(settings.pages?.documents, "/account/documents");
@@ -43,36 +53,32 @@ const module$1 = defineNuxtModule({
43
53
  pages: { index: indexPath, profile: profilePath, security: securityPath, documents: documentsPath, apiTokens: apiTokensPath },
44
54
  documents: settings.documents ?? []
45
55
  };
46
- if (indexPath || profilePath || securityPath || documentsPath || apiTokensPath) {
47
- extendPages((pages) => {
48
- const existingPaths = new Set(pages.map((page) => page.path));
49
- const children = [];
50
- const add = (path, name, file) => {
51
- if (path && !existingPaths.has(path)) children.push({ name, path, file: resolver.resolve(file) });
52
- };
53
- add(indexPath, "account-index", "./runtime/app/pages/index.vue");
54
- add(profilePath, "account-profile", "./runtime/app/pages/profile.vue");
55
- add(documentsPath, "account-documents", "./runtime/app/pages/documents.vue");
56
- add(securityPath, "account-security", "./runtime/app/pages/security.vue");
57
- add(apiTokensPath, "account-api-tokens", "./runtime/app/pages/api-tokens.vue");
58
- if (children.length) {
59
- pages.push({ name: "account-layout", path: "/__account", file: resolver.resolve("./runtime/app/pages/layout.vue"), children });
60
- }
61
- });
56
+ addModulePages("@frigear-nu/account", resolver, [
57
+ { name: "account-index", path: indexPath, file: "./runtime/app/pages/index.vue" },
58
+ { name: "account-profile", path: profilePath, file: "./runtime/app/pages/profile.vue" },
59
+ { name: "account-documents", path: documentsPath, file: "./runtime/app/pages/documents.vue" },
60
+ { name: "account-security", path: securityPath, file: "./runtime/app/pages/security.vue" },
61
+ { name: "account-api-tokens", path: apiTokensPath, file: "./runtime/app/pages/api-tokens.vue" }
62
+ ]);
63
+ const prefixes = ["", ...(nuxt.options.i18n?.locales ?? []).map((locale) => typeof locale === "string" ? locale : locale.code).map((code) => `/${code}`)];
64
+ const outside = [indexPath, profilePath, securityPath, documentsPath, apiTokensPath].filter((path) => !!path && path !== "/account" && !path.startsWith("/account/"));
65
+ const areaPatterns = prefixes.flatMap((prefix) => [`${prefix}/account`, `${prefix}/account/**`, ...outside.map((path) => `${prefix}${path}`)]);
66
+ if (settings.layout !== false) {
67
+ addLayout({ src: resolver.resolve("./runtime/app/layouts/account.vue"), filename: "account.vue" }, "account");
68
+ const rules = nuxt.options.routeRules ||= {};
69
+ for (const pattern of areaPatterns) {
70
+ rules[pattern] = { appLayout: "account", ...rules[pattern] };
71
+ }
62
72
  }
63
73
  if (hasNuxtModule("@frigear-nu/authorization")) {
64
- const prefixes = ["", ...(nuxt.options.i18n?.locales ?? []).map((locale) => typeof locale === "string" ? locale : locale.code).map((code) => `/${code}`)];
65
74
  const rules = nuxt.options.routeRules ||= {};
66
- const guarded = [indexPath, profilePath, securityPath, documentsPath, apiTokensPath].filter((path) => !!path && !path.startsWith("/account"));
67
- for (const prefix of prefixes) {
68
- for (const pattern of [`${prefix}/account`, `${prefix}/account/**`, ...guarded.map((path) => `${prefix}${path}`)]) {
69
- rules[pattern] = { authenticated: true, ...rules[pattern] };
70
- }
75
+ for (const pattern of areaPatterns) {
76
+ rules[pattern] = { authenticated: true, ...rules[pattern] };
71
77
  }
72
78
  } else {
73
79
  console.warn("[@frigear-nu/account] @frigear-nu/authorization is not installed: the account pages are not guarded. Install it and guard `/account/**` yourself.");
74
80
  }
75
- addOptionalMenuSource(nuxt, { file: resolver.resolve("./runtime/menus"), menu: "dashboard.account" });
81
+ addOptionalMenuSource(nuxt, { file: resolver.resolve("./runtime/menus"), menu: "account" });
76
82
  addComponentsDir({
77
83
  path: resolver.resolve("./runtime/app/components"),
78
84
  priority: -1
@@ -0,0 +1,11 @@
1
+ import type { ApiTokenScope, CreatedApiToken } from '../../utils/api-tokens.js';
2
+ type __VLS_Props = {
3
+ scopes: ApiTokenScope[];
4
+ };
5
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
+ close: (created: false | CreatedApiToken) => any;
7
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
+ onClose?: ((created: false | CreatedApiToken) => any) | undefined;
9
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
+ declare const _default: typeof __VLS_export;
11
+ export default _default;
@@ -0,0 +1,95 @@
1
+ <script setup>
2
+ import { z } from "zod";
3
+ const props = defineProps({
4
+ scopes: { type: Array, required: true }
5
+ });
6
+ const emit = defineEmits(["close"]);
7
+ const t = useModuleT("account.apiTokens");
8
+ const { busy, run } = useAction();
9
+ const scopeItems = computed(() => props.scopes.map((scope) => ({
10
+ label: scope.name === "*" ? t("allScopes", "Full access") : scope.name,
11
+ description: scope.description,
12
+ value: scope.name
13
+ })));
14
+ const expiryItems = computed(() => [
15
+ { label: t("expiry.30", "30 days"), value: 30 },
16
+ { label: t("expiry.90", "90 days"), value: 90 },
17
+ { label: t("expiry.365", "1 year"), value: 365 },
18
+ { label: t("expiry.never", "No expiry"), value: 0 }
19
+ ]);
20
+ const schema = z.object({
21
+ name: z.string().trim().min(1, t("name.required", "Give the token a name.")).max(80),
22
+ scopes: z.array(z.string()).min(1, t("scopes.required", "Pick at least one scope.")),
23
+ expiresInDays: z.number()
24
+ });
25
+ const state = reactive({ name: "", scopes: [], expiresInDays: 90 });
26
+ async function onSubmit(event) {
27
+ const created = await run(
28
+ () => $fetch("/api/auth/tokens", {
29
+ method: "POST",
30
+ body: { name: event.data.name, scopes: event.data.scopes, expiresInDays: event.data.expiresInDays || null }
31
+ }),
32
+ { error: t("createError", "Could not create the token.") }
33
+ );
34
+ if (created) emit("close", created);
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <FormSlideover
40
+ form-id="account-api-token"
41
+ :title="t('create.title', 'Create API token')"
42
+ :description="t('create.description', 'Tokens let scripts and tools such as MCP clients act as you. Give each one only the scopes it needs.')"
43
+ :submit-label="t('create.submit', 'Create API token')"
44
+ :loading="busy"
45
+ @close="emit('close', false)"
46
+ >
47
+ <UForm
48
+ id="account-api-token"
49
+ :schema="schema"
50
+ :state="state"
51
+ class="flex flex-col gap-4"
52
+ @submit="onSubmit"
53
+ >
54
+ <UFormField
55
+ :label="t('name.label', 'Name')"
56
+ :description="t('name.description', 'So you recognise the token later.')"
57
+ name="name"
58
+ required
59
+ >
60
+ <UInput
61
+ v-model="state.name"
62
+ :placeholder="t('name.placeholder', 'e.g. My MCP client')"
63
+ maxlength="80"
64
+ class="w-full"
65
+ />
66
+ </UFormField>
67
+ <UFormField
68
+ :label="t('scopes.label', 'Scopes')"
69
+ :description="t('scopes.description', 'What the token may do.')"
70
+ name="scopes"
71
+ required
72
+ >
73
+ <USelectMenu
74
+ v-model="state.scopes"
75
+ :items="scopeItems"
76
+ value-key="value"
77
+ multiple
78
+ :placeholder="t('scopes.placeholder', 'Choose what the token may do')"
79
+ class="w-full"
80
+ />
81
+ </UFormField>
82
+ <UFormField
83
+ :label="t('expiry.label', 'Expires')"
84
+ name="expiresInDays"
85
+ >
86
+ <USelect
87
+ v-model="state.expiresInDays"
88
+ :items="expiryItems"
89
+ value-key="value"
90
+ class="w-full"
91
+ />
92
+ </UFormField>
93
+ </UForm>
94
+ </FormSlideover>
95
+ </template>
@@ -0,0 +1,11 @@
1
+ import type { ApiTokenScope, CreatedApiToken } from '../../utils/api-tokens.js';
2
+ type __VLS_Props = {
3
+ scopes: ApiTokenScope[];
4
+ };
5
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
6
+ close: (created: false | CreatedApiToken) => any;
7
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
8
+ onClose?: ((created: false | CreatedApiToken) => any) | undefined;
9
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
+ declare const _default: typeof __VLS_export;
11
+ export default _default;
@@ -1,43 +1,23 @@
1
1
  <script setup>
2
2
  const t = useModuleT("account");
3
3
  const route = useRoute();
4
- const pages = useRuntimeConfig().public.account?.pages;
5
- const nuxtApp = useNuxtApp();
6
- const items = typeof useMenu === "function" ? useMenu("account").data : ref([]);
7
- const trimSlash = (path) => path.length > 1 ? path.replace(/\/+$/, "") : path;
8
- const stripLocale = (path) => {
9
- const trimmed = trimSlash(path);
10
- const first = trimmed.split("/")[1];
11
- const codes = (nuxtApp.$i18n?.locales?.value ?? []).map((locale) => typeof locale === "string" ? locale : locale.code);
12
- return first && codes.includes(first) ? trimmed.slice(first.length + 1) || "/" : trimmed;
13
- };
14
- const current = computed(() => {
15
- const path = stripLocale(route.path);
16
- return (items.value ?? []).find((item) => item.to && !item.external && stripLocale(item.to) === path);
4
+ const { current } = await useAccountNav();
5
+ const title = computed(() => {
6
+ const meta = route.meta.title;
7
+ return typeof meta === "string" && meta || current.value?.label || t("title", "Account");
17
8
  });
18
- const title = computed(() => current.value?.label ?? t("title", "Account"));
19
9
  const description = computed(() => current.value?.description);
10
+ useHead({ title });
20
11
  </script>
21
12
 
22
13
  <template>
23
- <UContainer>
24
- <UPageHeader
25
- :title="title"
26
- :description="description"
27
- :ui="{ wrapper: 'flex-row items-center justify-between gap-4', description: 'mt-2' }"
28
- >
29
- <UButton
30
- v-if="current && pages?.index"
31
- variant="link"
32
- icon="i-lucide-arrow-left"
33
- :to="pages.index"
34
- :label="t('backTo', 'Back to account')"
35
- class="-ml-3 mt-2"
36
- />
37
-
38
- <template #links>
39
- <AccountHeaderRight />
40
- </template>
41
- </UPageHeader>
42
- </UContainer>
14
+ <PageHeading
15
+ v-if="route.meta.heading !== false"
16
+ :title="title"
17
+ :description="description"
18
+ >
19
+ <template #actions>
20
+ <AccountHeaderRight />
21
+ </template>
22
+ </PageHeading>
43
23
  </template>
@@ -1,3 +1,12 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
1
+ /**
2
+ * The signed-in user's menu: who is signed in, the `account.dropdown` menu (account, admin, sign out), and the
3
+ * colour mode under Appearance. The entry for the area you are in is left out. `block` renders it as a full-width
4
+ * row for a sidebar footer (the admin shell), `collapsed` shrinks that row to the avatar.
5
+ */
6
+ type __VLS_Props = {
7
+ block?: boolean;
8
+ collapsed?: boolean;
9
+ };
10
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
2
11
  declare const _default: typeof __VLS_export;
3
12
  export default _default;
@@ -1,69 +1,118 @@
1
1
  <script setup>
2
- import { computedAsync } from "@vueuse/core";
2
+ const props = defineProps({
3
+ block: { type: Boolean, required: false },
4
+ collapsed: { type: Boolean, required: false }
5
+ });
6
+ const t = useModuleT("account");
3
7
  const { user } = useUserSession();
4
- const { data: dropdownItems } = useMenu("account.dropdown");
5
- const getFirstLetter = (name) => {
6
- return String(typeof name === "string" ? name.charAt(0)?.toUpperCase() : name);
8
+ const route = useRoute();
9
+ const colorMode = useColorMode();
10
+ const { localize, strip } = useLocaleLink();
11
+ const dropdown = typeof useMenu === "function" ? useMenu("account.dropdown").data : ref([]);
12
+ const sessionUser = computed(() => user.value);
13
+ const displayName = computed(() => sessionUser.value?.name || sessionUser.value?.email || String(sessionUser.value?.id ?? t("title", "Account")));
14
+ const avatar = computed(() => ({ src: sessionUser.value?.avatar || void 0, alt: displayName.value }));
15
+ const role = computed(() => sessionUser.value?.role || void 0);
16
+ const roleLabel = computed(() => role.value && role.value !== "user" ? role.value.charAt(0).toUpperCase() + role.value.slice(1) : void 0);
17
+ const isHere = (to) => {
18
+ const target = strip(to);
19
+ const current = strip(route.path);
20
+ return current === target || target !== "/" && current.startsWith(`${target}/`);
7
21
  };
8
- const items = computedAsync(async () => {
9
- const items2 = [];
10
- if (user.value) {
11
- items2.push([
12
- {
13
- type: "label",
14
- label: `${user.value.email || user.value.name || user.value.id}`,
15
- avatar: {
16
- text: getFirstLetter(user.value.email || user.value.name || user.value.id)
17
- },
18
- slot: "label"
19
- }
20
- ]);
22
+ const toItem = (item) => ({
23
+ label: item.label,
24
+ icon: item.icon,
25
+ to: item.to && !item.external ? localize(item.to) : item.to,
26
+ external: item.external,
27
+ replace: item.external
28
+ });
29
+ const appearance = (label, value, icon) => ({
30
+ label,
31
+ icon,
32
+ type: "checkbox",
33
+ checked: colorMode.preference === value,
34
+ onUpdateChecked: () => {
35
+ colorMode.preference = value;
21
36
  }
22
- const role = user.value?.role;
23
- const visible = (dropdownItems.value ?? []).filter((item) => {
37
+ });
38
+ const items = computed(() => {
39
+ const visible = (dropdown.value ?? []).filter((item) => {
24
40
  const roles = item.meta?.roles;
25
- return !roles?.length || !!role && roles.includes(role);
41
+ if (roles?.length && !(role.value && roles.includes(role.value))) return false;
42
+ return !item.to || item.external || !isHere(item.to);
26
43
  });
27
- items2.push(visible.map((item) => ({
28
- label: item.label,
29
- to: item.to,
30
- icon: item.icon,
31
- external: item.external,
32
- replace: item.external
33
- })));
34
- return items2;
44
+ const last = (item) => (item.order ?? 0) >= 99;
45
+ return [
46
+ sessionUser.value ? [{ type: "label", label: displayName.value, slot: "account" }] : [],
47
+ visible.filter((item) => !last(item)).map(toItem),
48
+ [{
49
+ label: t("appearance.label", "Appearance"),
50
+ icon: colorMode.preference === "dark" ? "i-lucide-moon" : colorMode.preference === "light" ? "i-lucide-sun" : "i-lucide-monitor",
51
+ children: [
52
+ appearance(t("appearance.light", "Light"), "light", "i-lucide-sun"),
53
+ appearance(t("appearance.dark", "Dark"), "dark", "i-lucide-moon"),
54
+ appearance(t("appearance.system", "System"), "system", "i-lucide-monitor")
55
+ ]
56
+ }],
57
+ visible.filter(last).map(toItem)
58
+ ].filter((group) => group.length);
35
59
  });
36
60
  </script>
37
61
 
38
62
  <template>
39
63
  <UDropdownMenu
40
64
  :items="items"
41
- :content="{ align: 'start' }"
42
- :ui="{ content: 'w-48' }"
65
+ :content="{ align: props.block ? 'start' : 'end', side: props.block ? 'top' : 'bottom', collisionPadding: 12 }"
66
+ :ui="{ content: props.block && !props.collapsed ? 'w-(--reka-dropdown-menu-trigger-width) min-w-56' : 'w-56' }"
43
67
  >
44
- <template #default>
45
- <UButton
46
- aria-label="Account Menu"
47
- color="neutral"
48
- variant="ghost"
49
- icon="i-lucide-user"
50
- />
51
- </template>
52
- <template #label="bindItem">
53
- <LazyUUser
54
- v-bind="bindItem.item"
68
+ <UButton
69
+ v-if="props.block"
70
+ :label="props.collapsed ? void 0 : displayName"
71
+ :avatar="avatar"
72
+ :trailing-icon="props.collapsed ? void 0 : 'i-lucide-chevrons-up-down'"
73
+ :square="props.collapsed"
74
+ :block="!props.collapsed"
75
+ :aria-label="t('accountMenu', 'Account menu')"
76
+ color="neutral"
77
+ variant="ghost"
78
+ class="data-[state=open]:bg-elevated"
79
+ :ui="{ trailingIcon: 'text-dimmed ms-auto', label: 'truncate' }"
80
+ />
81
+ <UButton
82
+ v-else
83
+ :avatar="avatar"
84
+ :aria-label="t('accountMenu', 'Account menu')"
85
+ color="neutral"
86
+ variant="ghost"
87
+ square
88
+ class="data-[state=open]:bg-elevated"
89
+ />
90
+
91
+ <template #account-label>
92
+ <UUser
93
+ :name="displayName"
94
+ :avatar="avatar"
95
+ size="sm"
96
+ :ui="{ name: 'truncate', description: 'truncate' }"
55
97
  >
56
- <template #name>
57
- <!-- <UBadge -->
58
- <!-- v-if="user && !userHasRole(user, 'user')" -->
59
- <!-- size="xs" -->
60
- <!-- variant="subtle" -->
61
- <!-- > -->
62
- <!-- {{ upperFirst(user.role) }} -->
63
- <!-- </UBadge> -->
64
- <div>{{ bindItem.item?.label }}</div>
98
+ <template
99
+ v-if="roleLabel || sessionUser?.name && sessionUser?.email"
100
+ #description
101
+ >
102
+ <span class="flex min-w-0 items-center gap-1.5">
103
+ <UBadge
104
+ v-if="roleLabel"
105
+ size="xs"
106
+ variant="subtle"
107
+ :label="roleLabel"
108
+ />
109
+ <span
110
+ v-if="sessionUser?.name && sessionUser?.email"
111
+ class="truncate"
112
+ >{{ sessionUser.email }}</span>
113
+ </span>
65
114
  </template>
66
- </LazyUUser>
115
+ </UUser>
67
116
  </template>
68
117
  </UDropdownMenu>
69
118
  </template>
@@ -1,3 +1,12 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
1
+ /**
2
+ * The signed-in user's menu: who is signed in, the `account.dropdown` menu (account, admin, sign out), and the
3
+ * colour mode under Appearance. The entry for the area you are in is left out. `block` renders it as a full-width
4
+ * row for a sidebar footer (the admin shell), `collapsed` shrinks that row to the avatar.
5
+ */
6
+ type __VLS_Props = {
7
+ block?: boolean;
8
+ collapsed?: boolean;
9
+ };
10
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
2
11
  declare const _default: typeof __VLS_export;
3
12
  export default _default;
@@ -1 +1,3 @@
1
1
  export { useAccount } from './useAccount.js';
2
+ export { useAccountNav } from './useAccountNav.js';
3
+ export type { AccountNavItem } from './useAccountNav.js';
@@ -1 +1,2 @@
1
1
  export { useAccount } from "./useAccount.js";
2
+ export { useAccountNav } from "./useAccountNav.js";
@@ -0,0 +1,20 @@
1
+ /** An entry of the account navigation: an item of the `account` menu, or a configured page without the menu module. */
2
+ export interface AccountNavItem {
3
+ id: string;
4
+ label: string;
5
+ description?: string;
6
+ icon?: string;
7
+ to: string;
8
+ external?: boolean;
9
+ }
10
+ /**
11
+ * The account navigation: the items of the `account` menu (so pages other modules add show up too), or, without
12
+ * `@frigear-nu/menu`, the account pages enabled in `account.settings.pages`. `links` are ready for
13
+ * `UNavigationMenu`/`PageTabs` (localised, `active` set, the overview only active on its own path) and `current` is
14
+ * the item the route belongs to. Await it, so the menu is resolved before the page renders on the server.
15
+ */
16
+ export declare function useAccountNav(): Promise<{
17
+ items: any;
18
+ links: any;
19
+ current: any;
20
+ }>;
@@ -0,0 +1,18 @@
1
+ export async function useAccountNav() {
2
+ const t = useModuleT("account");
3
+ const pages = useRuntimeConfig().public.account?.pages;
4
+ const { isActive, strip } = useLocaleLink();
5
+ const menu = typeof useMenu === "function" ? useMenu("account") : void 0;
6
+ const fallback = () => [
7
+ { id: "account-overview", to: pages?.index, label: t("menu.overview.label", "Overview"), description: t("menu.overview.description", "A summary of your account."), icon: "i-lucide-layout-dashboard" },
8
+ { id: "profile", to: pages?.profile, label: t("menu.profile.label", "Profile"), description: t("menu.profile.description", "Manage your profile details and email address."), icon: "i-lucide-user-round-pen" },
9
+ { id: "security", to: pages?.security, label: t("menu.security.label", "Security"), description: t("menu.security.description", "Change your password and manage passkeys."), icon: "i-lucide-shield-user" },
10
+ { id: "api-tokens", to: pages?.apiTokens, label: t("menu.apiTokens.label", "API tokens"), description: t("menu.apiTokens.description", "Create tokens for scripts and tools."), icon: "i-lucide-key-round" },
11
+ { id: "documents", to: pages?.documents, label: t("menu.documents.label", "Documents"), description: t("menu.documents.description", "Handbooks and other files for you."), icon: "i-lucide-files" }
12
+ ].filter((item) => !!item.to);
13
+ const items = computed(() => menu ? (menu.data.value ?? []).filter((item) => !!item.to).map((item) => ({ id: item.id ?? item.to, label: item.label, description: item.description, icon: item.icon, to: item.to, external: item.external })) : fallback());
14
+ const links = useNavLinks(items, { exact: pages?.index ? [pages.index] : [] });
15
+ const current = computed(() => items.value.filter((item) => !item.external && isActive(item.to, { exact: item.to === pages?.index })).sort((a, b) => strip(b.to).length - strip(a.to).length)[0]);
16
+ if (menu) await menu;
17
+ return { items, links, current };
18
+ }
@@ -0,0 +1,13 @@
1
+ declare var __VLS_47: {};
2
+ type __VLS_Slots = {} & {
3
+ default?: (props: typeof __VLS_47) => any;
4
+ };
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
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
9
+ type __VLS_WithSlots<T, S> = T & {
10
+ new (): {
11
+ $slots: S;
12
+ };
13
+ };