@frigear-nu/account 0.0.7 โ 0.0.9
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 +11 -6
- package/dist/module.d.mts +20 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +35 -40
- package/dist/runtime/app/components/Account/AccountHeader.d.vue.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeader.vue +43 -0
- package/dist/runtime/app/components/Account/AccountHeader.vue.d.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeaderRight.d.vue.ts +3 -0
- package/dist/runtime/app/components/Account/AccountHeaderRight.vue +18 -0
- package/dist/runtime/app/components/Account/AccountHeaderRight.vue.d.ts +3 -0
- package/dist/runtime/app/components/Account/AccountMenu.vue +7 -2
- package/dist/runtime/app/composables/useAccount.js +3 -3
- package/dist/runtime/app/pages/api-tokens.d.vue.ts +3 -0
- package/dist/runtime/app/pages/api-tokens.vue +308 -0
- package/dist/runtime/app/pages/api-tokens.vue.d.ts +3 -0
- package/dist/runtime/app/pages/documents.d.vue.ts +3 -0
- package/dist/runtime/app/pages/documents.vue +38 -0
- package/dist/runtime/app/pages/documents.vue.d.ts +3 -0
- package/dist/runtime/app/pages/layout.d.vue.ts +3 -0
- package/dist/runtime/app/pages/layout.vue +10 -0
- package/dist/runtime/app/pages/layout.vue.d.ts +3 -0
- package/dist/runtime/app/pages/profile.vue +188 -46
- package/dist/runtime/app/pages/security.vue +180 -60
- package/dist/runtime/app/utils/documents.d.ts +5 -0
- package/dist/runtime/app/utils/documents.js +7 -0
- package/dist/runtime/i18n/lang/da.json +120 -16
- package/dist/runtime/i18n/lang/en.json +120 -16
- package/dist/runtime/menus.js +24 -0
- package/package.json +20 -10
- package/dist/runtime/server/db/schema.sqlite.d.ts +0 -24
- package/dist/runtime/server/db/schema.sqlite.js +0 -4
- package/dist/runtime/server/routes/account/change-email.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/change-email.post.js +0 -20
- package/dist/runtime/server/routes/account/change-password.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/change-password.post.js +0 -26
- package/dist/runtime/server/routes/account/profile.post.d.ts +0 -2
- package/dist/runtime/server/routes/account/profile.post.js +0 -17
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @frigear-nu/account
|
|
2
2
|
|
|
3
|
-
A module for the account area (profile,
|
|
3
|
+
A module for the account area (profile, security, documents, API tokens pages) in the main frigear app. Migrated from
|
|
4
4
|
[`nuxt-frigear-nu`](https://github.com/Frigear-nu/nuxt-frigear-nu)'s `app/pages/account/**`.
|
|
5
5
|
|
|
6
6
|
## Features
|
|
@@ -8,13 +8,13 @@ A module for the account area (profile, email, password, security) in the main f
|
|
|
8
8
|
- ๐ค Update your profile details (name, avatar).
|
|
9
9
|
- โ๏ธ Change your email address.
|
|
10
10
|
- ๐ Change your password.
|
|
11
|
-
- ๐ Manage passkeys (via `@frigear-nu/
|
|
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
13
|
- ๐๏ธ Contributes "Profile"/"Security" tiles to `@frigear-nu/menu` (when installed), and an opt-in dashboard page
|
|
14
14
|
listing them.
|
|
15
15
|
|
|
16
|
-
This module
|
|
17
|
-
|
|
16
|
+
This module is a UI shell: it owns no tables and no mutation routes. The forms call the self-service routes of
|
|
17
|
+
`@frigear-nu/users` (`/api/users/me/*`) and the token routes of `@frigear-nu/auth`.
|
|
18
18
|
|
|
19
19
|
## Quick Setup
|
|
20
20
|
|
|
@@ -24,8 +24,8 @@ Install the module to your Nuxt application with one command:
|
|
|
24
24
|
npx nuxt module add @frigear-nu/account
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
This module requires [`@frigear-nu/users`](../users) to be installed in the host app, since
|
|
28
|
-
|
|
27
|
+
This module requires [`@frigear-nu/users`](../users) to be installed in the host app, since its forms call the
|
|
28
|
+
self-service routes there.
|
|
29
29
|
|
|
30
30
|
## Configuration
|
|
31
31
|
|
|
@@ -74,3 +74,8 @@ await changePassword({ currentPassword: 'old', newPassword: 'new-password', conf
|
|
|
74
74
|
|
|
75
75
|
Strings live under the `account.*` namespace in `da`/`en` locale files and can be overridden in your app's
|
|
76
76
|
`i18n/locales/<code>.json`. See the [documentation](https://docs.frigear.dev/modules/account#translations).
|
|
77
|
+
|
|
78
|
+
## Overriding parts
|
|
79
|
+
|
|
80
|
+
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.
|
package/dist/module.d.mts
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
2
|
|
|
3
|
+
/** A downloadable file listed on the documents page. */
|
|
4
|
+
type AccountDocument = {
|
|
5
|
+
id: string;
|
|
6
|
+
/** A string, or a map of locale -> string (falls back to `en`, then the first value). */
|
|
7
|
+
label: string | Record<string, string>;
|
|
8
|
+
/** Where the file is served from, e.g. `/assets/files/handbook.pdf`. */
|
|
9
|
+
path: string;
|
|
10
|
+
icon?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Only shown to users with one of these roles (the `role` of the session user). Omit to show to every signed-in
|
|
13
|
+
* user. This only decides what is *listed*: a file in `public/` can still be fetched by anyone who knows its URL.
|
|
14
|
+
*/
|
|
15
|
+
roles?: string[];
|
|
16
|
+
};
|
|
3
17
|
type PublicModuleOptions = {
|
|
4
18
|
pages: {
|
|
5
19
|
index?: string;
|
|
6
20
|
profile?: string;
|
|
7
21
|
security?: string;
|
|
22
|
+
documents?: string;
|
|
23
|
+
apiTokens?: string;
|
|
8
24
|
};
|
|
25
|
+
documents: AccountDocument[];
|
|
9
26
|
};
|
|
10
27
|
type ModuleOptions = {
|
|
11
28
|
settings?: {
|
|
@@ -13,7 +30,10 @@ type ModuleOptions = {
|
|
|
13
30
|
index?: string | boolean;
|
|
14
31
|
profile?: string | boolean;
|
|
15
32
|
security?: string | boolean;
|
|
33
|
+
documents?: string | boolean;
|
|
34
|
+
apiTokens?: string | boolean;
|
|
16
35
|
};
|
|
36
|
+
documents?: AccountDocument[];
|
|
17
37
|
};
|
|
18
38
|
};
|
|
19
39
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
import { defineNuxtModule, createResolver, hasNuxtModule,
|
|
2
|
-
import { registerModule, registerLocales,
|
|
1
|
+
import { defineNuxtModule, createResolver, hasNuxtModule, addImports, extendPages, addComponentsDir } from '@nuxt/kit';
|
|
2
|
+
import { registerModule, registerTailwindSource, registerLocales, resolvePagePath, addOptionalMenuSource } from '@frigear-nu/module-kit/utils';
|
|
3
3
|
|
|
4
|
-
function resolvePagePath(setting, defaultPath) {
|
|
5
|
-
if (typeof setting === "string") return setting;
|
|
6
|
-
if (setting === true) return defaultPath;
|
|
7
|
-
return void 0;
|
|
8
|
-
}
|
|
9
4
|
const module$1 = defineNuxtModule({
|
|
10
5
|
meta: { name: "@frigear-nu/account", configKey: "account" },
|
|
11
6
|
defaults: {
|
|
@@ -18,6 +13,8 @@ const module$1 = defineNuxtModule({
|
|
|
18
13
|
"@frigear-nu/module-kit": {
|
|
19
14
|
version: ">=0.0.1"
|
|
20
15
|
},
|
|
16
|
+
"@frigear-nu/auth": { version: ">=0.0.1" },
|
|
17
|
+
// The profile, email and password forms call its self-service routes.
|
|
21
18
|
"@frigear-nu/users": {
|
|
22
19
|
version: ">=0.0.1"
|
|
23
20
|
},
|
|
@@ -29,58 +26,56 @@ const module$1 = defineNuxtModule({
|
|
|
29
26
|
async setup(options, nuxt) {
|
|
30
27
|
registerModule("@frigear-nu/account", nuxt);
|
|
31
28
|
const resolver = createResolver(import.meta.url);
|
|
29
|
+
registerTailwindSource(nuxt, resolver.resolve("./runtime/app"));
|
|
32
30
|
registerLocales(nuxt, resolver.resolve("./runtime/i18n/lang"));
|
|
33
31
|
const settings = options.settings ?? {};
|
|
34
32
|
const withMenus = hasNuxtModule("@frigear-nu/menu");
|
|
35
|
-
addServerHandler({
|
|
36
|
-
route: "/api/account/profile",
|
|
37
|
-
method: "post",
|
|
38
|
-
handler: resolver.resolve("./runtime/server/routes/account/profile.post")
|
|
39
|
-
});
|
|
40
|
-
addServerHandler({
|
|
41
|
-
route: "/api/account/change-email",
|
|
42
|
-
method: "post",
|
|
43
|
-
handler: resolver.resolve("./runtime/server/routes/account/change-email.post")
|
|
44
|
-
});
|
|
45
|
-
addServerHandler({
|
|
46
|
-
route: "/api/account/change-password",
|
|
47
|
-
method: "post",
|
|
48
|
-
handler: resolver.resolve("./runtime/server/routes/account/change-password.post")
|
|
49
|
-
});
|
|
50
33
|
addImports([{ name: "useAccount", from: resolver.resolve("./runtime/app/composables/useAccount") }]);
|
|
51
34
|
const profilePath = resolvePagePath(settings.pages?.profile, "/account/profile");
|
|
52
35
|
const securityPath = resolvePagePath(settings.pages?.security, "/account/security");
|
|
36
|
+
const documentsPath = resolvePagePath(settings.pages?.documents, "/account/documents");
|
|
37
|
+
const apiTokensPath = resolvePagePath(settings.pages?.apiTokens, "/account/api-tokens");
|
|
53
38
|
const indexPath = withMenus ? resolvePagePath(settings.pages?.index, "/account") : void 0;
|
|
54
39
|
if (!withMenus && settings.pages?.index) {
|
|
55
40
|
console.warn("[@frigear-nu/account] `settings.pages.index` was set, but `@frigear-nu/menu` is not installed - skipping the dashboard page.");
|
|
56
41
|
}
|
|
57
42
|
nuxt.options.runtimeConfig.public.account = {
|
|
58
|
-
pages: { index: indexPath, profile: profilePath, security: securityPath }
|
|
43
|
+
pages: { index: indexPath, profile: profilePath, security: securityPath, documents: documentsPath, apiTokens: apiTokensPath },
|
|
44
|
+
documents: settings.documents ?? []
|
|
59
45
|
};
|
|
60
|
-
if (indexPath || profilePath || securityPath) {
|
|
46
|
+
if (indexPath || profilePath || securityPath || documentsPath || apiTokensPath) {
|
|
61
47
|
extendPages((pages) => {
|
|
62
48
|
const existingPaths = new Set(pages.map((page) => page.path));
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 });
|
|
71
60
|
}
|
|
72
61
|
});
|
|
73
62
|
}
|
|
74
|
-
if (
|
|
75
|
-
const
|
|
76
|
-
|
|
63
|
+
if (hasNuxtModule("@frigear-nu/authorization")) {
|
|
64
|
+
const prefixes = ["", ...(nuxt.options.i18n?.locales ?? []).map((locale) => typeof locale === "string" ? locale : locale.code).map((code) => `/${code}`)];
|
|
65
|
+
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
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
console.warn("[@frigear-nu/account] @frigear-nu/authorization is not installed: the account pages are not guarded. Install it and guard `/account/**` yourself.");
|
|
77
74
|
}
|
|
75
|
+
addOptionalMenuSource(nuxt, { file: resolver.resolve("./runtime/menus"), menu: "dashboard.account" });
|
|
78
76
|
addComponentsDir({
|
|
79
|
-
path: resolver.resolve("./runtime/app/components")
|
|
80
|
-
|
|
81
|
-
extendDatabaseTable(nuxt, {
|
|
82
|
-
resolver,
|
|
83
|
-
file: (dialect) => `./runtime/server/db/schema.${dialect}`
|
|
77
|
+
path: resolver.resolve("./runtime/app/components"),
|
|
78
|
+
priority: -1
|
|
84
79
|
});
|
|
85
80
|
}
|
|
86
81
|
});
|
|
@@ -0,0 +1,3 @@
|
|
|
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>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const t = useModuleT("account");
|
|
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);
|
|
17
|
+
});
|
|
18
|
+
const title = computed(() => current.value?.label ?? t("title", "Account"));
|
|
19
|
+
const description = computed(() => current.value?.description);
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<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>
|
|
43
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
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>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,3 @@
|
|
|
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>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
const t = useModuleT("account");
|
|
3
|
+
const { user } = useUserSession();
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<div
|
|
8
|
+
v-if="user"
|
|
9
|
+
class="flex flex-col"
|
|
10
|
+
>
|
|
11
|
+
<div class="text-sm text-muted">
|
|
12
|
+
{{ t("signedInAs", "Signed in as") }}
|
|
13
|
+
</div>
|
|
14
|
+
<div class="text-primary">
|
|
15
|
+
{{ user.email }}
|
|
16
|
+
</div>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
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>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -19,13 +19,18 @@ const items = computedAsync(async () => {
|
|
|
19
19
|
}
|
|
20
20
|
]);
|
|
21
21
|
}
|
|
22
|
-
|
|
22
|
+
const role = user.value?.role;
|
|
23
|
+
const visible = (dropdownItems.value ?? []).filter((item) => {
|
|
24
|
+
const roles = item.meta?.roles;
|
|
25
|
+
return !roles?.length || !!role && roles.includes(role);
|
|
26
|
+
});
|
|
27
|
+
items2.push(visible.map((item) => ({
|
|
23
28
|
label: item.label,
|
|
24
29
|
to: item.to,
|
|
25
30
|
icon: item.icon,
|
|
26
31
|
external: item.external,
|
|
27
32
|
replace: item.external
|
|
28
|
-
}))
|
|
33
|
+
})));
|
|
29
34
|
return items2;
|
|
30
35
|
});
|
|
31
36
|
</script>
|
|
@@ -17,19 +17,19 @@ export const useAccount = () => {
|
|
|
17
17
|
}
|
|
18
18
|
function updateProfileDetails(payload) {
|
|
19
19
|
return withRequest(
|
|
20
|
-
() => $fetch("/api/
|
|
20
|
+
() => $fetch("/api/users/me/profile", { method: "POST", body: payload }),
|
|
21
21
|
"Failed to update your profile."
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
24
|
function changeEmailAddress(payload) {
|
|
25
25
|
return withRequest(
|
|
26
|
-
() => $fetch("/api/
|
|
26
|
+
() => $fetch("/api/users/me/change-email", { method: "POST", body: payload }),
|
|
27
27
|
"Failed to change your email address."
|
|
28
28
|
);
|
|
29
29
|
}
|
|
30
30
|
function changePassword(payload) {
|
|
31
31
|
return withRequest(
|
|
32
|
-
() => $fetch("/api/
|
|
32
|
+
() => $fetch("/api/users/me/change-password", { method: "POST", body: payload }),
|
|
33
33
|
"Failed to change your password."
|
|
34
34
|
);
|
|
35
35
|
}
|
|
@@ -0,0 +1,3 @@
|
|
|
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>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
defineOptions({ name: "AccountApiTokensPage" });
|
|
4
|
+
const t = useModuleT("account.apiTokens");
|
|
5
|
+
const toast = useToast();
|
|
6
|
+
const { data: tokens, refresh } = await useFetch("/api/auth/tokens", { key: "account-api-tokens", default: () => [] });
|
|
7
|
+
const { data: scopes } = await useFetch("/api/auth/scopes", { key: "account-api-token-scopes", default: () => [] });
|
|
8
|
+
const scopeItems = computed(() => (scopes.value ?? []).map((scope) => ({
|
|
9
|
+
label: scope.name === "*" ? t("allScopes", "Full access") : scope.name,
|
|
10
|
+
description: scope.description,
|
|
11
|
+
value: scope.name
|
|
12
|
+
})));
|
|
13
|
+
const expiryItems = computed(() => [
|
|
14
|
+
{ label: t("expiry.30", "30 days"), value: 30 },
|
|
15
|
+
{ label: t("expiry.90", "90 days"), value: 90 },
|
|
16
|
+
{ label: t("expiry.365", "1 year"), value: 365 },
|
|
17
|
+
{ label: t("expiry.never", "No expiry"), value: 0 }
|
|
18
|
+
]);
|
|
19
|
+
const schema = z.object({
|
|
20
|
+
name: z.string().trim().min(1, t("name.required", "Give the token a name.")).max(80),
|
|
21
|
+
scopes: z.array(z.string()).min(1, t("scopes.required", "Pick at least one scope.")),
|
|
22
|
+
expiresInDays: z.number()
|
|
23
|
+
});
|
|
24
|
+
const state = reactive({ name: "", scopes: [], expiresInDays: 90 });
|
|
25
|
+
const creating = ref(false);
|
|
26
|
+
const revokingId = ref(null);
|
|
27
|
+
const revealed = ref(null);
|
|
28
|
+
function errorMessage(e, fallback) {
|
|
29
|
+
const err = e;
|
|
30
|
+
return err?.data?.statusMessage ?? err?.statusMessage ?? fallback;
|
|
31
|
+
}
|
|
32
|
+
async function onCreate(event) {
|
|
33
|
+
creating.value = true;
|
|
34
|
+
try {
|
|
35
|
+
const created = await $fetch("/api/auth/tokens", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
body: { name: event.data.name, scopes: event.data.scopes, expiresInDays: event.data.expiresInDays || null }
|
|
38
|
+
});
|
|
39
|
+
revealed.value = { name: created.name, token: created.token };
|
|
40
|
+
state.name = "";
|
|
41
|
+
state.scopes = [];
|
|
42
|
+
await refresh();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
toast.add({ title: errorMessage(e, t("createError", "Could not create the token.")), color: "error" });
|
|
45
|
+
} finally {
|
|
46
|
+
creating.value = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function copyText(text, success) {
|
|
50
|
+
try {
|
|
51
|
+
await navigator.clipboard.writeText(text);
|
|
52
|
+
toast.add({ title: success, color: "success" });
|
|
53
|
+
} catch {
|
|
54
|
+
toast.add({ title: t("copyError", "Could not copy. Select the token and copy it manually."), color: "warning" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const copyToken = () => revealed.value && copyText(revealed.value.token, t("copied", "Token copied."));
|
|
58
|
+
async function onRevoke(token) {
|
|
59
|
+
revokingId.value = token.id;
|
|
60
|
+
try {
|
|
61
|
+
await $fetch(`/api/auth/tokens/${token.id}`, { method: "DELETE" });
|
|
62
|
+
toast.add({ title: t("revoked", "Token revoked."), color: "success" });
|
|
63
|
+
await refresh();
|
|
64
|
+
} catch (e) {
|
|
65
|
+
toast.add({ title: errorMessage(e, t("revokeError", "Could not revoke the token.")), color: "error" });
|
|
66
|
+
} finally {
|
|
67
|
+
revokingId.value = null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const mcpEnabled = useRuntimeConfig().public.users?.mcp === true;
|
|
71
|
+
const activeConnect = ref("claude");
|
|
72
|
+
const mcpUrl = `${useRequestURL().origin}/mcp`;
|
|
73
|
+
const tokenPlaceholder = "<your-api-token>";
|
|
74
|
+
const shownToken = computed(() => revealed.value?.token ?? tokenPlaceholder);
|
|
75
|
+
const connectTabs = computed(() => [
|
|
76
|
+
{
|
|
77
|
+
label: "Claude",
|
|
78
|
+
value: "claude",
|
|
79
|
+
steps: t("connect.claude.steps", "Claude.ai and Claude Desktop: open Settings \u2192 Connectors \u2192 Add custom connector, paste the URL below and sign in when asked. No token needed."),
|
|
80
|
+
code: mcpUrl,
|
|
81
|
+
extraSteps: t("connect.claude.cli", "Claude Code (uses an API token):"),
|
|
82
|
+
extraCode: `claude mcp add --transport http frigear ${mcpUrl} --header "Authorization: Bearer ${shownToken.value}"`
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
label: "ChatGPT",
|
|
86
|
+
value: "chatgpt",
|
|
87
|
+
steps: t("connect.chatgpt.steps", "ChatGPT: open Settings \u2192 Connectors \u2192 Advanced, turn on Developer mode, then create a connector with the URL below. Choose OAuth and sign in when asked. No token needed."),
|
|
88
|
+
code: mcpUrl
|
|
89
|
+
}
|
|
90
|
+
]);
|
|
91
|
+
const dateFormatter = new Intl.DateTimeFormat(void 0, { dateStyle: "medium" });
|
|
92
|
+
function formatDate(value) {
|
|
93
|
+
if (!value) return "";
|
|
94
|
+
const date = new Date(value);
|
|
95
|
+
return Number.isNaN(date.getTime()) ? "" : dateFormatter.format(date);
|
|
96
|
+
}
|
|
97
|
+
function statusColor(status) {
|
|
98
|
+
return status === "ok" ? "success" : status === "expired" ? "warning" : "neutral";
|
|
99
|
+
}
|
|
100
|
+
</script>
|
|
101
|
+
|
|
102
|
+
<template>
|
|
103
|
+
<UContainer>
|
|
104
|
+
<UPage>
|
|
105
|
+
<UPageBody>
|
|
106
|
+
<UPageCard
|
|
107
|
+
:title="t('create.title', 'Create an API token')"
|
|
108
|
+
:description="t('create.description', 'Tokens let scripts and tools such as MCP clients act as you. Give each one only the scopes it needs.')"
|
|
109
|
+
variant="subtle"
|
|
110
|
+
>
|
|
111
|
+
<UAlert
|
|
112
|
+
v-if="revealed"
|
|
113
|
+
color="success"
|
|
114
|
+
variant="subtle"
|
|
115
|
+
icon="i-lucide-key-round"
|
|
116
|
+
:title="t('reveal.title', 'Copy your new token now')"
|
|
117
|
+
:description="t('reveal.description', 'This is the only time it is shown. If you lose it, create a new one.')"
|
|
118
|
+
>
|
|
119
|
+
<template #actions>
|
|
120
|
+
<UInput
|
|
121
|
+
:model-value="revealed.token"
|
|
122
|
+
readonly
|
|
123
|
+
class="w-full sm:w-96"
|
|
124
|
+
:aria-label="t('reveal.label', 'New token')"
|
|
125
|
+
@focus="$event.target.select()"
|
|
126
|
+
/>
|
|
127
|
+
<UButton
|
|
128
|
+
icon="i-lucide-copy"
|
|
129
|
+
:label="t('copy', 'Copy')"
|
|
130
|
+
@click="copyToken"
|
|
131
|
+
/>
|
|
132
|
+
<UButton
|
|
133
|
+
color="neutral"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
:label="t('reveal.done', 'Done')"
|
|
136
|
+
@click="revealed = null"
|
|
137
|
+
/>
|
|
138
|
+
</template>
|
|
139
|
+
</UAlert>
|
|
140
|
+
|
|
141
|
+
<UForm
|
|
142
|
+
:schema="schema"
|
|
143
|
+
:state="state"
|
|
144
|
+
class="space-y-4"
|
|
145
|
+
@submit="onCreate"
|
|
146
|
+
>
|
|
147
|
+
<UFormField
|
|
148
|
+
:label="t('name.label', 'Name')"
|
|
149
|
+
name="name"
|
|
150
|
+
required
|
|
151
|
+
>
|
|
152
|
+
<UInput
|
|
153
|
+
v-model="state.name"
|
|
154
|
+
:placeholder="t('name.placeholder', 'e.g. My MCP client')"
|
|
155
|
+
maxlength="80"
|
|
156
|
+
class="w-full"
|
|
157
|
+
/>
|
|
158
|
+
</UFormField>
|
|
159
|
+
<UFormField
|
|
160
|
+
:label="t('scopes.label', 'Scopes')"
|
|
161
|
+
name="scopes"
|
|
162
|
+
required
|
|
163
|
+
>
|
|
164
|
+
<USelectMenu
|
|
165
|
+
v-model="state.scopes"
|
|
166
|
+
:items="scopeItems"
|
|
167
|
+
value-key="value"
|
|
168
|
+
multiple
|
|
169
|
+
:placeholder="t('scopes.placeholder', 'Choose what the token may do')"
|
|
170
|
+
class="w-full"
|
|
171
|
+
/>
|
|
172
|
+
</UFormField>
|
|
173
|
+
<UFormField
|
|
174
|
+
:label="t('expiry.label', 'Expires')"
|
|
175
|
+
name="expiresInDays"
|
|
176
|
+
>
|
|
177
|
+
<USelect
|
|
178
|
+
v-model="state.expiresInDays"
|
|
179
|
+
:items="expiryItems"
|
|
180
|
+
value-key="value"
|
|
181
|
+
class="w-full sm:max-w-xs"
|
|
182
|
+
/>
|
|
183
|
+
</UFormField>
|
|
184
|
+
<UButton
|
|
185
|
+
type="submit"
|
|
186
|
+
:loading="creating"
|
|
187
|
+
:label="t('create.submit', 'Create token')"
|
|
188
|
+
/>
|
|
189
|
+
</UForm>
|
|
190
|
+
</UPageCard>
|
|
191
|
+
|
|
192
|
+
<UPageCard
|
|
193
|
+
v-if="mcpEnabled"
|
|
194
|
+
:title="t('connect.title', 'Connect to MCP')"
|
|
195
|
+
:description="t('connect.description', 'Use your account from an AI assistant. Your API token is only filled into the commands right after you create it.')"
|
|
196
|
+
variant="subtle"
|
|
197
|
+
>
|
|
198
|
+
<UTabs
|
|
199
|
+
v-model="activeConnect"
|
|
200
|
+
:items="connectTabs"
|
|
201
|
+
:content="false"
|
|
202
|
+
class="w-full"
|
|
203
|
+
/>
|
|
204
|
+
<template
|
|
205
|
+
v-for="tab in connectTabs"
|
|
206
|
+
:key="tab.value"
|
|
207
|
+
>
|
|
208
|
+
<div
|
|
209
|
+
v-if="tab.value === activeConnect"
|
|
210
|
+
class="min-w-0 max-w-full space-y-3"
|
|
211
|
+
>
|
|
212
|
+
<p class="text-sm text-muted">
|
|
213
|
+
{{ tab.steps }}
|
|
214
|
+
</p>
|
|
215
|
+
<div class="relative min-w-0">
|
|
216
|
+
<pre class="max-w-full whitespace-pre-wrap break-all rounded-md bg-elevated p-3 pr-12 text-sm"><code>{{ tab.code }}</code></pre>
|
|
217
|
+
<UButton
|
|
218
|
+
class="absolute right-2 top-2"
|
|
219
|
+
color="neutral"
|
|
220
|
+
variant="ghost"
|
|
221
|
+
size="xs"
|
|
222
|
+
icon="i-lucide-copy"
|
|
223
|
+
:aria-label="t('copy', 'Copy')"
|
|
224
|
+
@click="copyText(tab.code, t('copiedSnippet', 'Copied.'))"
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
<template v-if="tab.extraCode">
|
|
228
|
+
<p class="text-sm text-muted">
|
|
229
|
+
{{ tab.extraSteps }}
|
|
230
|
+
</p>
|
|
231
|
+
<div class="relative min-w-0">
|
|
232
|
+
<pre class="max-w-full whitespace-pre-wrap break-all rounded-md bg-elevated p-3 pr-12 text-sm"><code>{{ tab.extraCode }}</code></pre>
|
|
233
|
+
<UButton
|
|
234
|
+
class="absolute right-2 top-2"
|
|
235
|
+
color="neutral"
|
|
236
|
+
variant="ghost"
|
|
237
|
+
size="xs"
|
|
238
|
+
icon="i-lucide-copy"
|
|
239
|
+
:aria-label="t('copy', 'Copy')"
|
|
240
|
+
@click="copyText(tab.extraCode ?? '', t('copiedSnippet', 'Copied.'))"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</template>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
|
246
|
+
</UPageCard>
|
|
247
|
+
|
|
248
|
+
<UPageCard
|
|
249
|
+
:title="t('list.title', 'Your tokens')"
|
|
250
|
+
:description="t('list.description', 'Revoke a token to stop it working immediately.')"
|
|
251
|
+
variant="subtle"
|
|
252
|
+
>
|
|
253
|
+
<ul
|
|
254
|
+
v-if="tokens?.length"
|
|
255
|
+
class="divide-y divide-default"
|
|
256
|
+
>
|
|
257
|
+
<li
|
|
258
|
+
v-for="token in tokens"
|
|
259
|
+
:key="token.id"
|
|
260
|
+
class="flex items-center justify-between gap-3 py-3"
|
|
261
|
+
>
|
|
262
|
+
<div class="min-w-0">
|
|
263
|
+
<p class="flex items-center gap-2 truncate font-medium text-highlighted">
|
|
264
|
+
{{ token.name }}
|
|
265
|
+
<UBadge
|
|
266
|
+
:color="statusColor(token.status)"
|
|
267
|
+
variant="subtle"
|
|
268
|
+
:label="t(`status.${token.status}`, token.status)"
|
|
269
|
+
/>
|
|
270
|
+
</p>
|
|
271
|
+
<p class="font-mono text-sm text-muted">
|
|
272
|
+
{{ token.prefix }}โฆ{{ token.last4 }}
|
|
273
|
+
</p>
|
|
274
|
+
<p class="text-sm text-muted">
|
|
275
|
+
{{ token.scopes.join(", ") }}
|
|
276
|
+
</p>
|
|
277
|
+
<p class="text-sm text-muted">
|
|
278
|
+
{{ t("list.created", "Created {date}", { date: formatDate(token.createdAt) }) }}
|
|
279
|
+
<template v-if="token.lastUsedAt">
|
|
280
|
+
ยท {{ t("list.lastUsed", "Last used {date}", { date: formatDate(token.lastUsedAt) }) }}
|
|
281
|
+
</template>
|
|
282
|
+
<template v-if="token.expiresAt">
|
|
283
|
+
ยท {{ t("list.expires", "Expires {date}", { date: formatDate(token.expiresAt) }) }}
|
|
284
|
+
</template>
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
<UButton
|
|
288
|
+
v-if="token.status !== 'revoked'"
|
|
289
|
+
color="error"
|
|
290
|
+
variant="ghost"
|
|
291
|
+
icon="i-lucide-trash-2"
|
|
292
|
+
:loading="revokingId === token.id"
|
|
293
|
+
:aria-label="t('revoke', 'Revoke token')"
|
|
294
|
+
@click="onRevoke(token)"
|
|
295
|
+
/>
|
|
296
|
+
</li>
|
|
297
|
+
</ul>
|
|
298
|
+
<p
|
|
299
|
+
v-else
|
|
300
|
+
class="text-sm text-muted"
|
|
301
|
+
>
|
|
302
|
+
{{ t("list.empty", "You have not created any tokens yet.") }}
|
|
303
|
+
</p>
|
|
304
|
+
</UPageCard>
|
|
305
|
+
</UPageBody>
|
|
306
|
+
</UPage>
|
|
307
|
+
</UContainer>
|
|
308
|
+
</template>
|
|
@@ -0,0 +1,3 @@
|
|
|
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>;
|
|
2
|
+
declare const _default: typeof __VLS_export;
|
|
3
|
+
export default _default;
|