diff --git a/dashboard/src/components/ProfileView.vue b/dashboard/src/components/ProfileView.vue index 97e74a5ca..c0845fec0 100644 --- a/dashboard/src/components/ProfileView.vue +++ b/dashboard/src/components/ProfileView.vue @@ -51,6 +51,20 @@ + +

{{ $t('profile.appPasswords.title') }}

+ + + +

{{ $t('profile.apiTokens.title') }}

+ + + +

{{ $t('profile.loginTokens.title') }}

+ +

{{ $t('profile.loginTokens.description', { webadminTokenCount: webadminTokens.length, cliTokenCount: cliTokens.length }) }}

+ +
@@ -59,9 +73,11 @@ import { useI18n } from 'vue-i18n'; import { ref, onMounted, useTemplateRef } from 'vue'; import { Button, Dropdown, InputDialog } from 'pankow'; +import { TOKEN_TYPES } from '../constants.js'; import Card from './Card.vue'; import ProfileModel from '../models/ProfileModel.js'; import CloudronModel from '../models/CloudronModel.js'; +import TokensModel from '../models/TokensModel.js'; const i18n = useI18n(); const t = i18n.t; @@ -70,33 +86,15 @@ const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_OR const profileModel = ProfileModel.create(API_ORIGIN, localStorage.token); const cloudronModel = CloudronModel.create(API_ORIGIN, localStorage.token); +const tokensModel = TokensModel.create(API_ORIGIN, localStorage.token); -// TODO what is this? -const config = ref({}); - +const config = ref({}); // TODO what is this? const user = ref({}); + +const inputDialog = useTemplateRef('inputDialog'); + const languages = ref([]); const language = ref(''); -const inputDialog = useTemplateRef('inputDialog'); -const avatarFileInput = useTemplateRef('avatarFileInput'); - -onMounted(async () => { - user.value = await profileModel.get(); - - const langs = await cloudronModel.languages(); - languages.value = langs.map(l => { - return { - id: l, - display: t(`lang.${l}`) - }; - }).sort((a, b) => { - return a.display.localeCompare(b.display); - }); - - const usedLang = window.localStorage.NG_TRANSLATE_LANG_KEY || 'en'; - language.value = languages.value.find(l => l.id === usedLang).id; -}); - async function onSelectLanguage(lang) { window.localStorage.NG_TRANSLATE_LANG_KEY = lang.id; @@ -190,6 +188,7 @@ async function onLogout() { await profileModel.logout(); } +const avatarFileInput = useTemplateRef('avatarFileInput'); async function onAvatarChanged() { if (!avatarFileInput.value.files[0]) return; await profileModel.setAvatar(avatarFileInput.value.files[0]); @@ -200,6 +199,47 @@ async function onAvatarChanged() { user.value.avatarUrl = u.toString(); } +// Tokens +const webadminTokens = ref([]); +const cliTokens = ref([]); +const apiTokens = ref([]); +const revokeTokensBusy = ref(false); +async function onRevokeAllWebAndCliTokens() { + revokeTokensBusy.value = true; + + // filter current access token to be able to logout still + const tokens = webadminTokens.value.concat(cliTokens.value).filter(t => t.accessToken !== localStorage.token); + for (const token of tokens) { + await tokensModel.remove(token.id); + } + + await profileModel.logout(); +} + +onMounted(async () => { + user.value = await profileModel.get(); + + const langs = await cloudronModel.languages(); + languages.value = langs.map(l => { + return { + id: l, + display: t(`lang.${l}`) + }; + }).sort((a, b) => { + return a.display.localeCompare(b.display); + }); + + const usedLang = window.localStorage.NG_TRANSLATE_LANG_KEY || 'en'; + language.value = languages.value.find(l => l.id === usedLang).id; + + const tokens = await tokensModel.list(); + + // dashboard and development clientIds were issued with 7.5.0 + webadminTokens.value = tokens.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_WEBADMIN || c.clientId === TOKEN_TYPES.ID_DEVELOPMENT || c.clientId === 'dashboard' || c.clientId === 'development'; }); + cliTokens.value = tokens.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_CLI; }); + apiTokens.value = tokens.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_SDK; }); +}); + diff --git a/dashboard/src/constants.js b/dashboard/src/constants.js index 24b8cffd4..83df3f548 100644 --- a/dashboard/src/constants.js +++ b/dashboard/src/constants.js @@ -83,6 +83,14 @@ const APP_TYPES = { const PROXY_APP_ID = 'io.cloudron.builtin.appproxy'; +// sync up with tokens.js +const TOKEN_TYPES = { + ID_WEBADMIN: 'cid-webadmin', // dashboard + ID_DEVELOPMENT: 'cid-development', // dashboard development + ID_CLI: 'cid-cli', // cloudron cli + ID_SDK: 'cid-sdk', // created by user via dashboard +}; + // named exports export { APP_TYPES, @@ -93,6 +101,7 @@ export { ROLES, TASK_TYPES, PROXY_APP_ID, + TOKEN_TYPES, }; // default export @@ -105,4 +114,5 @@ export default { ROLES, TASK_TYPES, PROXY_APP_ID, + TOKEN_TYPES, }; diff --git a/dashboard/src/models/TokensModel.js b/dashboard/src/models/TokensModel.js new file mode 100644 index 000000000..d384a3964 --- /dev/null +++ b/dashboard/src/models/TokensModel.js @@ -0,0 +1,40 @@ + +import { fetcher } from 'pankow'; + +function create(origin, accessToken) { + return { + name: 'TokensModel', + async list() { + let error, result; + try { + result = await fetcher.get(`${origin}/api/v1/tokens`, { access_token: accessToken }); + } catch (e) { + error = e; + } + + if (error || result.status !== 200) { + console.error('Failed to list tokens.', error || result.status); + return []; + } + + return result.body.tokens; + }, + async remove(id) { + let error, result; + try { + result = await fetcher.del(`${origin}/api/v1/tokens/${id}`, { access_token: accessToken }); + } catch (e) { + error = e; + } + + if (error) return error; + if (result.status !== 204) return result; + + return null; + }, + }; +} + +export default { + create, +}; diff --git a/dashboard/src/style.css b/dashboard/src/style.css index 497d0fc7c..72a9dfa06 100644 --- a/dashboard/src/style.css +++ b/dashboard/src/style.css @@ -27,6 +27,7 @@ html, body { h2 { font-weight: 400; + font-size: 24px; } a { diff --git a/dashboard/src/theme.scss b/dashboard/src/theme.scss index c7161b067..e5f694401 100644 --- a/dashboard/src/theme.scss +++ b/dashboard/src/theme.scss @@ -11,7 +11,7 @@ $brand-danger: #ff4c4c !default; $body-bg: #f4f4f4; $font-family-sans-serif: "Noto Sans", Helvetica, Arial, sans-serif; -$font-family-heading: "Noto Sans", Helvetica, Arial, sans-serif; +$font-family-heading: "Noto Sans Light", Helvetica, Arial, sans-serif; $navbar-default-link-color: $brand-primary !default; $navbar-default-link-hover-color: #62bdfc !default; @@ -531,6 +531,10 @@ h1 { font-size: 28px; } +h2 { + font-size: 24px; +} + .view-header { padding-left: 15px; padding-right: 20px;