Move toplevel views into views/
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
<script>
|
||||
|
||||
import { Button, ButtonGroup, Dropdown, Icon, TableView, TextInput } from 'pankow';
|
||||
|
||||
import { APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import ApplinksModel from '../models/ApplinksModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import ApplinkDialog from '../components/ApplinkDialog.vue';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
const appsModel = AppsModel.create(API_ORIGIN, accessToken);
|
||||
const domainsModel = DomainsModel.create(API_ORIGIN, accessToken);
|
||||
const applinksModel = ApplinksModel.create(API_ORIGIN, accessToken);
|
||||
|
||||
const VIEW_TYPE = {
|
||||
LIST: 'list',
|
||||
GRID: 'grid',
|
||||
};
|
||||
|
||||
let refreshInterval;
|
||||
|
||||
export default {
|
||||
name: 'AppsView',
|
||||
components: {
|
||||
ApplinkDialog,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
Icon,
|
||||
TableView,
|
||||
TextInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
API_ORIGIN,
|
||||
APP_TYPES,
|
||||
VIEW_TYPE,
|
||||
ready: false,
|
||||
filter: '',
|
||||
profile: this.$root.profile,
|
||||
apps: [],
|
||||
viewType: (localStorage.appsView && (localStorage.appsView === VIEW_TYPE.GRID || localStorage.appsView === VIEW_TYPE.LIST)) ? localStorage.appsView : VIEW_TYPE.GRID,
|
||||
tagFilter: '',
|
||||
tagFilterOptions: [{
|
||||
id: '',
|
||||
name: 'All Tags',
|
||||
}],
|
||||
domainFilter: '',
|
||||
domainFilterOptions: [{
|
||||
id: '',
|
||||
domain: 'All Domains',
|
||||
}],
|
||||
stateFilter: '',
|
||||
stateFilterOptions: [
|
||||
{ id: '', label: 'All States' },
|
||||
{ id: 'running', label: 'Running' },
|
||||
{ id: 'stopped', label: 'Stopped' },
|
||||
{ id: 'update_available', label: 'Update Available' },
|
||||
{ id: 'not_responding', label: 'Not Responding' },
|
||||
],
|
||||
listColumns: {
|
||||
icon: {
|
||||
width: '32px'
|
||||
},
|
||||
label: {
|
||||
label: 'Label',
|
||||
sort: true
|
||||
},
|
||||
domain: {
|
||||
label: 'Location',
|
||||
sort: true
|
||||
},
|
||||
status: {},
|
||||
appTitle: {
|
||||
label: 'App Title',
|
||||
sort: true
|
||||
},
|
||||
sso: {
|
||||
label: 'Login',
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredApps() {
|
||||
return this.apps.filter(a => {
|
||||
return a.fqdn.indexOf(this.filter) !== -1;
|
||||
}).filter(a => {
|
||||
if (!this.domainFilter) return true;
|
||||
return a.domain === this.domainFilter;
|
||||
}).filter(a => {
|
||||
if (!this.tagFilter) return true;
|
||||
return a.tags.indexOf(this.tagFilter) !== -1;
|
||||
}).filter(a => {
|
||||
if (!this.stateFilter) return true;
|
||||
|
||||
if (this.stateFilter === 'running') return a.runState === RSTATES.RUNNING && a.health === HSTATES.HEALTHY && a.installationState === ISTATES.INSTALLED;
|
||||
if (this.stateFilter === 'stopped') return a.runState === RSTATES.STOPPED;
|
||||
|
||||
// TODO implement this
|
||||
// if (this.stateFilter === 'update_available') return !!(Client.getConfig().update[a.id] && Client.getConfig().update[a.id].manifest.version && Client.getConfig().update[a.id].manifest.version !== a.manifest.version);
|
||||
if (this.stateFilter === 'update_available') return false;
|
||||
|
||||
return a.runState === RSTATES.RUNNING && (a.health !== HSTATES.HEALTHY || a.installationState !== ISTATES.INSTALLED); // not responding
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
installationStateLabel: AppsModel.installationStateLabel,
|
||||
installationActive: AppsModel.installationActive,
|
||||
appProgressMessage: AppsModel.appProgressMessage,
|
||||
openAppEdit(app) {
|
||||
if (app.type === APP_TYPES.LINK) this.$refs.applinkDialog.open(app);
|
||||
else window.location.href = `#/app/${app.id}/info`;
|
||||
},
|
||||
onOpenApp(app, event) {
|
||||
function stopEvent() {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (app.installationState !== ISTATES.INSTALLED) {
|
||||
if (app.installationState === ISTATES.ERROR && this.isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
|
||||
return stopEvent();
|
||||
}
|
||||
|
||||
// app.health can also be null to indicate insufficient data
|
||||
if (!app.health) return stopEvent();
|
||||
if (app.runState === RSTATES.STOPPED) return stopEvent();
|
||||
|
||||
if (app.health === HSTATES.UNHEALTHY || app.health === HSTATES.ERROR || app.health === HSTATES.DEAD) {
|
||||
if (this.isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
|
||||
return stopEvent();
|
||||
}
|
||||
|
||||
// TODO
|
||||
// if (app.pendingPostInstallConfirmation && $scope.appPostInstallConfirm) {
|
||||
// $scope.appPostInstallConfirm.show(app);
|
||||
// return stopEvent();
|
||||
// }
|
||||
},
|
||||
isOperator(app) {
|
||||
return app.accessLevel === 'operator' || app.accessLevel === 'admin';
|
||||
},
|
||||
async refreshApps() {
|
||||
const apps = await appsModel.list();
|
||||
const applinks = await applinksModel.list();
|
||||
|
||||
// amend properties to mimick full app
|
||||
for (const applink of applinks) {
|
||||
applink.type = APP_TYPES.LINK;
|
||||
applink.fqdn = applink.upstreamUri;
|
||||
applink.manifest = { addons: {}};
|
||||
applink.installationState = ISTATES.INSTALLED;
|
||||
applink.runState = RSTATES.RUNNING;
|
||||
applink.health = HSTATES.HEALTHY;
|
||||
applink.iconUrl = `/api/v1/applinks/${applink.id}/icon?access_token=${accessToken}&ts=${applink.ts}`;
|
||||
applink.accessLevel = this.$root.profile.isAtLeastAdmin ? 'admin' : 'user';
|
||||
|
||||
apps.push(applink);
|
||||
}
|
||||
|
||||
this.apps = apps;
|
||||
|
||||
// gets all tags used by all apps, flattens the arrays and new Set() will dedupe
|
||||
const tags = [...new Set(this.apps.map(a => a.tags).flat())].map(t => { return { id: t, name: t }; });
|
||||
this.tagFilterOptions = [{ id: '', name: 'All Tags', }].concat(tags);
|
||||
},
|
||||
toggleView() {
|
||||
this.viewType = this.viewType === VIEW_TYPE.LIST ? VIEW_TYPE.GRID : VIEW_TYPE.LIST;
|
||||
localStorage.appsView = this.viewType;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.refreshApps();
|
||||
|
||||
const domains = await domainsModel.list();
|
||||
this.domainFilterOptions = this.domainFilterOptions.concat(domains.map(d => { d.id = d.domain; return d; }));
|
||||
this.domainFilter = this.domainFilterOptions[0].id;
|
||||
|
||||
this.stateFilter = this.stateFilterOptions[0].id;
|
||||
this.tagFilter = this.tagFilterOptions[0].id;
|
||||
|
||||
this.ready = true;
|
||||
|
||||
refreshInterval = setInterval(this.refreshApps, 5000);
|
||||
},
|
||||
async unmounted() {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ApplinkDialog ref="applinkDialog" @success="refreshApps()"/>
|
||||
|
||||
<h1 class="section-header">
|
||||
{{ $t('apps.title') }}
|
||||
<div>
|
||||
<TextInput v-model="filter" placeholder="Filter ..." />
|
||||
<ButtonGroup>
|
||||
<Dropdown outline tool :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter"></Dropdown>
|
||||
<Dropdown outline tool :options="stateFilterOptions" option-key="id" v-model="stateFilter"></Dropdown>
|
||||
<Dropdown outline tool :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter"></Dropdown>
|
||||
</ButtonGroup>
|
||||
<Button tool @click="toggleView()" :icon="viewType === VIEW_TYPE.GRID ? 'fas fa-list' : 'fas fa-grip'"></Button>
|
||||
</div>
|
||||
</h1>
|
||||
|
||||
<div v-show="ready">
|
||||
<TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
|
||||
<a v-for="app in filteredApps" :key="app.id" class="grid-item" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank" v-tooltip="app.fqdn">
|
||||
<div class="config" v-show="isOperator(app)" @click.prevent="openAppEdit(app)"><Icon icon="fa-solid fa-cog" /></div>
|
||||
<img :src="API_ORIGIN + app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
<div class="grid-item-label">{{ app.label || app.subdomain || app.fqdn }}</div>
|
||||
<div class="grid-item-task-label">{{ installationStateLabel(app) }}</div>
|
||||
<div class="apps-progress" v-show="isOperator(app)">
|
||||
<div class="apps-progress-filled" :style="{ width: app.progress+'%' }"></div>
|
||||
</div>
|
||||
</a>
|
||||
</TransitionGroup>
|
||||
|
||||
<div class="list" v-if="viewType === VIEW_TYPE.LIST">
|
||||
<TableView :columns="listColumns" :model="filteredApps">
|
||||
<template #icon="slotProps">
|
||||
<a :href="'https://' + slotProps.fqdn" target="_blank">
|
||||
<img class="list-icon" :src="API_ORIGIN + slotProps.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
</a>
|
||||
</template>
|
||||
<template #label="slotProps">
|
||||
<a :href="'https://' + slotProps.fqdn" target="_blank" v-tooltip="slotProps.fqdn">
|
||||
{{ slotProps.label || slotProps.subdomain || slotProps.fqdn }}
|
||||
</a>
|
||||
</template>
|
||||
<template #appTitle="slotProps">
|
||||
{{ slotProps.manifest.title }}
|
||||
</template>
|
||||
<template #domain="slotProps">
|
||||
<a :href="'https://' + slotProps.fqdn" target="_blank">
|
||||
{{ slotProps.fqdn }}
|
||||
</a>
|
||||
</template>
|
||||
<template #status="slotProps">
|
||||
<div class="list-status">
|
||||
{{ installationStateLabel(slotProps) }}
|
||||
<div class="apps-progress" v-show="isOperator(slotProps)">
|
||||
<div class="apps-progress-filled" :style="{ width: slotProps.progress+'%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #sso="slotProps">
|
||||
<div v-show="slotProps.type !== APP_TYPES.LINK">
|
||||
<Icon icon="fa-brands fa-openid" v-show="slotProps.ssoAuth && slotProps.manifest.addons.oidc" v-tooltip="$t('apps.auth.openid')" />
|
||||
<Icon icon="fas fa-user" v-show="slotProps.ssoAuth && (!slotProps.manifest.addons.oidc && !slotProps.manifest.addons.email)" v-tooltip="$t('apps.auth.sso')" />
|
||||
<Icon icon="far fa-user" v-show="!slotProps.ssoAuth && !slotProps.manifest.addons.email" v-tooltip="$t('apps.auth.nosso')" />
|
||||
<Icon icon="fas fa-envelope" v-show="slotProps.manifest.addons.email" v-tooltip="$t('apps.auth.email')" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="slotProps">
|
||||
<div class="actions">
|
||||
<ButtonGroup>
|
||||
<Button tool v-if="slotProps.type !== APP_TYPES.LINK" :href="'/logs.html?appId=' + slotProps.id" target="_blank" v-tooltip="$t('app.logsActionTooltip')" icon="fas fa-align-left"></Button>
|
||||
<Button tool v-if="slotProps.type !== APP_TYPES.PROXIED && slotProps.type !== APP_TYPES.LINK" :href="'/terminal.html?id=' + slotProps.id" target="_blank" v-tooltip="$t('app.terminalActionTooltip')" icon="fa fa-terminal"></Button>
|
||||
<Button tool v-if="slotProps.manifest.addons.localstorage" :href="'/filemanager.html#/home/app/' + slotProps.id" target="_blank" v-tooltip="$t('app.filemanagerActionTooltip')" icon="fas fa-folder"></Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<Button tool @click="openAppEdit(slotProps)" icon="fa-solid fa-cog"></Button>
|
||||
</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</div>
|
||||
|
||||
<div class="empty-placeholder" v-if="apps.length === 0">
|
||||
<!-- for admins -->
|
||||
<div v-if="profile.isAtLeastAdmin">
|
||||
<h4>{{ $t('apps.noApps.title') }}</h4>
|
||||
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })"></h5>
|
||||
</div>
|
||||
|
||||
<!-- for non-admins -->
|
||||
<div v-if="!profile.isAtLeastAdmin">
|
||||
<h4>{{ $t('apps.noAccess.title') }}</h4>
|
||||
<h5>{{ $t('apps.noAccess.description') }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.grid-animation-move,
|
||||
.grid-animation-enter-active,
|
||||
.grid-animation-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-animation-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.grid-animation-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
.list-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.list-status {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grid-item-task-label {
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
color: var(--pankow-text-color);
|
||||
}
|
||||
|
||||
.apps-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
color: var(--pankow-text-color);
|
||||
}
|
||||
|
||||
.apps-progress-filled {
|
||||
background-color: var(--pankow-color-primary);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
z-index: 1;
|
||||
background-image: linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);
|
||||
background-size: 40px 40px;
|
||||
animation: apps-progress-bar-stripes 1s linear infinite;
|
||||
transition: width 300ms;
|
||||
}
|
||||
|
||||
@keyframes apps-progress-bar-stripes {
|
||||
from {
|
||||
background-position: 40px 0;
|
||||
}
|
||||
to {
|
||||
background-position: 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: right;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
tr:hover .actions {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: 300ms;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 190px;
|
||||
height: 180px;
|
||||
margin: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
.grid-item:focus,
|
||||
.grid-item:hover {
|
||||
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.grid-item img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.grid-item-label {
|
||||
font-size: 18px;
|
||||
font-weight: 100;
|
||||
margin: 5px 0 5px 0;
|
||||
color: var(--pankow-text-color);
|
||||
}
|
||||
|
||||
.grid-item:focus .grid-item-label,
|
||||
.grid-item:hover .grid-item-label {
|
||||
text-decoration: none;
|
||||
color: var(--accent-color);;
|
||||
}
|
||||
|
||||
.config {
|
||||
position: absolute;
|
||||
color: var(--pankow-text-color);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-top-right-radius: 10px;
|
||||
right: 0;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config:focus,
|
||||
.config:hover {
|
||||
text-decoration: none;
|
||||
color: var(--accent-color);;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.grid-item:focus .config,
|
||||
.grid-item:hover .config {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, onMounted } from 'vue';
|
||||
import { Button, ButtonGroup, TextInput } from 'pankow';
|
||||
import AppstoreModel from '../models/AppstoreModel.js';
|
||||
import AppInstallDialog from '../components/AppInstallDialog.vue';
|
||||
import ApplinkDialog from '../components/ApplinkDialog.vue';
|
||||
import { PROXY_APP_ID } from '../constants.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
|
||||
const appstoreModel = AppstoreModel.create(API_ORIGIN, localStorage.token);
|
||||
|
||||
const ready = ref(false);
|
||||
const proxyApp = ref();
|
||||
const apps = ref([]);
|
||||
const search = ref('');
|
||||
const filteredApps = computed(() => {
|
||||
if (!search.value) return apps.value;
|
||||
|
||||
const s = search.value.toLowerCase();
|
||||
|
||||
return apps.value.filter(a => {
|
||||
if (a.manifest.title.toLowerCase().indexOf(s) !== -1) return true;
|
||||
if (a.manifest.tagline.toLowerCase().indexOf(s) !== -1) return true;
|
||||
if (a.manifest.tags.join().toLowerCase().indexOf(s) !== -1) return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
const filteredAllApps = computed(() => {
|
||||
return filteredApps.value.filter(a => !a.featured);
|
||||
});
|
||||
const filteredPopularApps = computed(() => {
|
||||
return filteredApps.value.filter(a => a.featured);
|
||||
});
|
||||
const appInstallDialog = useTemplateRef('appInstallDialog');
|
||||
const searchInput = useTemplateRef('searchInput');
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
|
||||
function onAppInstallDialogClose() {
|
||||
window.location.href = '#/appstore';
|
||||
}
|
||||
|
||||
function onApplinkDialogOpen() {
|
||||
applinkDialog.value.open();
|
||||
}
|
||||
|
||||
function onApplinkDialogSuccess() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
function onInstall(app) {
|
||||
window.location.href = `#/appstore/${app.manifest.id}?version=${app.manifest.version}`;
|
||||
appInstallDialog.value.open(app);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
apps.value = await appstoreModel.list();
|
||||
ready.value = true;
|
||||
|
||||
const query = window.location.hash.slice('#/appstore/'.length);
|
||||
if (query) {
|
||||
const appId = query.split('?')[0];
|
||||
const version = query.slice(query.indexOf('version=')+'version='.length);
|
||||
|
||||
const app = await appstoreModel.get(appId, version);
|
||||
if (app) {
|
||||
appInstallDialog.value.open(app);
|
||||
} else {
|
||||
console.error('No such version found');
|
||||
}
|
||||
} else {
|
||||
searchInput.value.$el.focus();
|
||||
}
|
||||
|
||||
proxyApp.value = await appstoreModel.get(PROXY_APP_ID);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkDialogSuccess()"/>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div></div>
|
||||
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :placeholder="$t('appstore.searchPlaceholder')" style="max-width: 100%; width: 500px;"/>
|
||||
<ButtonGroup>
|
||||
<Button outline icon="fas fa-exchange-alt" @click="onInstall(proxyApp)">{{ $t('apps.addAppproxyAction') }}</Button>
|
||||
<Button outline icon="fas fa-link" @click="onApplinkDialogOpen()">{{ $t('apps.addApplinkAction') }}</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
<div v-if="!search">
|
||||
<h4 v-show="filteredPopularApps.length">{{ $t('appstore.category.popular') }}</h4>
|
||||
<TransitionGroup name="grid-animation" tag="div" class="grid" v-show="ready">
|
||||
<div class="item" v-for="app in filteredPopularApps" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)">
|
||||
<img class="icon" :src="app.iconUrl" />
|
||||
<div class="description">
|
||||
<div class="title">{{ app.manifest.title }}</div>
|
||||
<div class="tagline">{{ app.manifest.tagline }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<h4 v-show="filteredAllApps.length">{{ $t('appstore.category.all') }}</h4>
|
||||
<TransitionGroup name="grid-animation" tag="div" class="grid" v-show="ready">
|
||||
<div class="item" v-for="app in filteredAllApps" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)">
|
||||
<img class="icon" :src="app.iconUrl" />
|
||||
<div class="description">
|
||||
<div class="title">{{ app.manifest.title }}</div>
|
||||
<div class="tagline">{{ app.manifest.tagline }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
<div v-else>
|
||||
<TransitionGroup name="grid-animation" tag="div" class="grid" v-show="ready">
|
||||
<div class="item" v-for="app in filteredApps" :key="app.id" :ref="'item-' + app.id" @click="onInstall(app)">
|
||||
<img class="icon" :src="app.iconUrl" />
|
||||
<div class="description">
|
||||
<div class="title">{{ app.manifest.title }}</div>
|
||||
<div class="tagline">{{ app.manifest.tagline }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.grid-animation-move,
|
||||
.grid-animation-enter-active,
|
||||
.grid-animation-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.grid-animation-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.grid-animation-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 300px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
.item.active {
|
||||
width: 100%;
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup>
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Dropdown, Dialog, InputDialog, TextInput } from 'pankow';
|
||||
import { TOKEN_TYPES } from '../constants.js';
|
||||
import AppPasswords from '../components/AppPasswords.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import ApiTokens from '../components/ApiTokens.vue';
|
||||
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
import CloudronModel from '../models/CloudronModel.js';
|
||||
import TokensModel from '../models/TokensModel.js';
|
||||
|
||||
const profileModel = ProfileModel.create(API_ORIGIN, localStorage.token);
|
||||
const cloudronModel = CloudronModel.create(API_ORIGIN, localStorage.token);
|
||||
const tokensModel = TokensModel.create(API_ORIGIN, localStorage.token);
|
||||
|
||||
const config = ref({}); // TODO what is this?
|
||||
const user = ref({});
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
// Language selector
|
||||
const languages = ref([]);
|
||||
const language = ref('');
|
||||
async function onSelectLanguage(lang) {
|
||||
window.localStorage.NG_TRANSLATE_LANG_KEY = lang.id;
|
||||
|
||||
const error = await profileModel.setLanguage(lang.id);
|
||||
if (error) console.error('Failed to set language', error);
|
||||
else window.location.reload();
|
||||
|
||||
// TODO dynamically change lange instead of reloading
|
||||
}
|
||||
|
||||
|
||||
// Profile edits
|
||||
async function onChangeDisplayName(currentDisplayName) {
|
||||
const displayName = await inputDialog.value.prompt({
|
||||
message: t('profile.changeDisplayName.title'),
|
||||
modal: false,
|
||||
value: currentDisplayName,
|
||||
confirmStyle: 'success',
|
||||
confirmLabel: t('main.dialog.save'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
});
|
||||
|
||||
if (!displayName || currentDisplayName === displayName) return;
|
||||
|
||||
const error = await profileModel.setDisplayName(displayName);
|
||||
if (error) return console.error('Failed to set displayName', error);
|
||||
|
||||
user.value = await profileModel.get();
|
||||
}
|
||||
|
||||
async function onChangeEmail(currentEmail) {
|
||||
const result = await inputDialog.value.prompt({
|
||||
message: [ t('profile.changeEmail.title'), t('profile.changeEmail.password') ],
|
||||
type: [ 'email', 'password' ],
|
||||
modal: false,
|
||||
value: [ currentEmail, '' ],
|
||||
confirmStyle: 'success',
|
||||
confirmLabel: t('main.dialog.save'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
});
|
||||
|
||||
if (!result || !result[0] || !result[1] || currentEmail === result[0]) return;
|
||||
|
||||
const error = await profileModel.setEmail(result[0], result[1]);
|
||||
if (error) return console.error('Failed to set email', error);
|
||||
|
||||
user.value = await profileModel.get();
|
||||
}
|
||||
|
||||
async function onChangeFallbackEmail(currentFallbackEmail) {
|
||||
const result = await inputDialog.value.prompt({
|
||||
message: [ t('profile.changeFallbackEmail.title'), t('profile.changeEmail.password') ],
|
||||
type: [ 'email', 'password' ],
|
||||
modal: false,
|
||||
value: [ currentFallbackEmail, '' ],
|
||||
confirmStyle: 'success',
|
||||
confirmLabel: t('main.dialog.save'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
});
|
||||
|
||||
if (!result || !result[1] || currentFallbackEmail === result[0]) return;
|
||||
|
||||
const error = await profileModel.setFallbackEmail(result[0], result[1]);
|
||||
if (error) return console.error('Failed to set fallback email', error);
|
||||
|
||||
user.value = await profileModel.get();
|
||||
}
|
||||
|
||||
const avatarFileInput = useTemplateRef('avatarFileInput');
|
||||
async function onAvatarChanged() {
|
||||
if (!avatarFileInput.value.files[0]) return;
|
||||
await profileModel.setAvatar(avatarFileInput.value.files[0]);
|
||||
|
||||
// invalidate and refresh profile avatar url
|
||||
const u = new URL(user.value.avatarUrl);
|
||||
u.searchParams.set('ts', Date.now());
|
||||
user.value.avatarUrl = u.toString();
|
||||
}
|
||||
|
||||
|
||||
// Password changes
|
||||
async function onPasswordChange() {
|
||||
const result = await inputDialog.value.prompt({
|
||||
message: [ t('profile.changePassword.newPassword'), t('profile.changePassword.newPasswordRepeat'), t('profile.changePassword.currentPassword') ],
|
||||
type: [ 'password', 'password', 'password' ],
|
||||
modal: false,
|
||||
confirmStyle: 'success',
|
||||
confirmLabel: t('main.dialog.save'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
});
|
||||
|
||||
if (!result || !result[0] || !result[1] || !result[2] || result[0] === result[1]) return;
|
||||
|
||||
const error = await profileModel.setPassword(result[2], result[0]);
|
||||
if (error) return console.error('Failed to change password', error);
|
||||
}
|
||||
|
||||
async function onPasswordReset() {
|
||||
const error = await profileModel.sendPasswordReset(user.value.email);
|
||||
if (error) return console.error('Failed to reset password:', error);
|
||||
|
||||
window.pankow.notify({ type: 'success', timeout: 5000, text: t('profile.passwordResetNotification.title') + '. ' + t('profile.passwordResetNotification.body', { email: user.value.fallbackEmail || user.value.email }) });
|
||||
}
|
||||
|
||||
|
||||
// Tokens
|
||||
const webadminTokens = ref([]);
|
||||
const cliTokens = 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) {
|
||||
const [error] = await tokensModel.remove(token.id);
|
||||
if (error) console.error(error);
|
||||
}
|
||||
|
||||
await profileModel.logout();
|
||||
}
|
||||
|
||||
|
||||
// 2fa
|
||||
const mandatory2FAHelp = ref('');
|
||||
const twoFASecret = ref('');
|
||||
const twoFATotpToken = ref('');
|
||||
const twoFAQRCode = ref('');
|
||||
const twoFAEnableError = ref('');
|
||||
const twoFADialog = useTemplateRef('twoFADialog');
|
||||
|
||||
async function onOpenTwoFASetupDialog() {
|
||||
const [error, result] = await profileModel.setTwoFASecret();
|
||||
if (error) return console.error(error);
|
||||
|
||||
twoFAEnableError.value = '';
|
||||
twoFATotpToken.value = '';
|
||||
twoFASecret.value = result.secret;
|
||||
twoFAQRCode.value = result.qrcode;
|
||||
|
||||
twoFADialog.value.open();
|
||||
}
|
||||
|
||||
async function onTwoFAEnable() {
|
||||
const [error] = await profileModel.enableTwoFA(twoFATotpToken.value);
|
||||
if (error) return twoFAEnableError.value = error.body ? error.body.message : 'Internal error';
|
||||
user.value = await profileModel.get();
|
||||
|
||||
twoFADialog.value.close();
|
||||
}
|
||||
|
||||
async function onTwoFADisable() {
|
||||
const password = await inputDialog.value.prompt({
|
||||
message: t('profile.disable2FA.title'),
|
||||
modal: true,
|
||||
placeholder: t('appstore.accountDialog.password'),
|
||||
type: 'password',
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
});
|
||||
|
||||
if (!password) return;
|
||||
|
||||
const [error] = await profileModel.disableTwoFA(password);
|
||||
if (error) return onTwoFADisable();
|
||||
|
||||
user.value = await profileModel.get();
|
||||
}
|
||||
|
||||
|
||||
// Init
|
||||
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 [error, tokens] = await tokensModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
// 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; });
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="twoFADialog"
|
||||
:title="$t('profile.enable2FA.title')">
|
||||
<div style="text-align: center; max-width: 420px">
|
||||
<p v-show="mandatory2FAHelp">{{ $t('profile.enable2FA.description') }}</p>
|
||||
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
<img :src="twoFAQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
|
||||
<small>{{ twoFASecret }}</small>
|
||||
<br/>
|
||||
<br/>
|
||||
<p class="has-error" v-show="twoFAEnableError">{{ twoFAEnableError }} </p>
|
||||
<form @submit.prevent="onTwoFAEnable()">
|
||||
<input type="submit" style="display: none;" :disabled="!twoFATotpToken"/>
|
||||
<FormGroup>
|
||||
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
|
||||
<TextInput v-model="twoFATotpToken" id="totpTokenInput" />
|
||||
</FormGroup>
|
||||
<Button @click="onTwoFAEnable()" :disabled="!twoFATotpToken">{{ $t('profile.enable2FA.enable') }}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('profile.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="profileModel.logout()" icon="fa fa-sign-out">{{ $t('main.logout') }}</Button>
|
||||
</template>
|
||||
|
||||
<div style="display: flex;">
|
||||
<div style="width: 150px;">
|
||||
<input type="file" ref="avatarFileInput" style="display: none" accept="image/*" @change="onAvatarChanged()"/>
|
||||
<div class="settings-avatar" :style="`background-image: url('${user.avatarUrl}');`" @click="avatarFileInput.click()">
|
||||
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex-grow: 1;">
|
||||
<table style="width: 100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-muted">{{ $t('main.username') }}</td>
|
||||
<td style="width: 100px; height: 34px;">{{ user.username }}</td>
|
||||
<td style="width: 32px"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">{{ $t('main.displayName') }}</td>
|
||||
<td style="white-space: nowrap;">{{ user.displayName }}</td>
|
||||
<td><Button small tool outline @click="onChangeDisplayName(user.displayName)" v-show="!user.source && !config.profileLocked" icon="fa fa-edit text-small" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">{{ $t('profile.primaryEmail') }}</td>
|
||||
<td style="white-space: nowrap;">{{ user.email }}</td>
|
||||
<td><Button small tool outline @click="onChangeEmail(user.email)" v-show="!user.source && !config.profileLocked" icon="fa fa-edit text-small" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">{{ $t('profile.passwordRecoveryEmail') }}</td>
|
||||
<td style="white-space: nowrap;">{{ user.fallbackEmail }}</td>
|
||||
<td><Button small tool outline @click="onChangeFallbackEmail(user.fallbackEmail)" v-show="!user.source && !config.profileLocked" icon="fa fa-edit text-small" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted">{{ $t('profile.language') }}</td>
|
||||
<td colspan="2" class="text-right"><Dropdown small tool outline v-model="language" :options="languages" option-label="display" option-key="id" @select="onSelectLanguage"/></td>
|
||||
</tr>
|
||||
<tr v-show="!user.source">
|
||||
<td colspan="3" class="text-right">
|
||||
<!-- <Button tool @click="onPasswordReset()">{{ $t('profile.passwordResetAction') }}</Button> -->
|
||||
<Button tool @click="onPasswordChange()">{{ $t('profile.changePasswordAction') }}</Button>
|
||||
<Button tool v-show="!user.source && !config.external2FA" @click="user.twoFactorAuthenticationEnabled ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<AppPasswords/>
|
||||
<ApiTokens v-show="user.isAtLeastAdmin"/>
|
||||
|
||||
<Section :title="$t('profile.loginTokens.title')">
|
||||
<p>{{ $t('profile.loginTokens.description', { webadminTokenCount: webadminTokens.length, cliTokenCount: cliTokens.length }) }}</p>
|
||||
<Button danger :loading="revokeTokensBusy" :disabled="revokeTokensBusy" @click="onRevokeAllWebAndCliTokens()">{{ $t('profile.loginTokens.logoutAll') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script>
|
||||
|
||||
import { fetcher, Button } from 'pankow';
|
||||
import { marked } from 'marked';
|
||||
|
||||
import Section from '../components/Section.vue';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
export default {
|
||||
name: 'SupportView',
|
||||
components: {
|
||||
Button,
|
||||
Section
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
ready: false,
|
||||
sshSupportEnabled: false,
|
||||
toggleSshSupportError: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
description() {
|
||||
return marked.parse(this.$t('support.help.description', {
|
||||
docsLink: 'https://docs.cloudron.io/?support_view',
|
||||
packagingLink: 'https://docs.cloudron.io/custom-apps/tutorial/?support_view',
|
||||
forumLink: 'https://forum.cloudron.io/'
|
||||
}));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async toggleSshSupport() {
|
||||
this.toggleSshSupportError = '';
|
||||
|
||||
const res = await fetcher.post(`${API_ORIGIN}/api/v1/support/remote_support`, { enable: !this.sshSupportEnabled }, { access_token: accessToken });
|
||||
if (res.status === 412 || res.status === 417) this.toggleSshSupportError = res.body;
|
||||
else if (res.status !== 202) console.error(res.body);
|
||||
|
||||
this.sshSupportEnabled = !this.sshSupportEnabled;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
const response = await fetcher.get(`${API_ORIGIN}/api/v1/support/remote_support`, { access_token: accessToken });
|
||||
this.sshSupportEnabled = response.body.enabled;
|
||||
this.ready = true;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<h1 class="section-header">{{ $t('support.title') }}</h1>
|
||||
|
||||
<Section :title="$t('support.help.title')">
|
||||
<div v-html="description"></div>
|
||||
</Section>
|
||||
|
||||
<Section :title="$t('support.remoteSupport.title')">
|
||||
<h2 class="text-center" v-show="!ready"><i class="fa fa-circle-notch fa-spin"></i></h2>
|
||||
<div v-show="ready">
|
||||
<p>{{ $t('support.remoteSupport.description') }}</p>
|
||||
<b>{{ $t('support.remoteSupport.warning') }}</b>
|
||||
<br/>
|
||||
<br/>
|
||||
<b class="pull-left text-danger text-bold" v-show="toggleSshSupportError">{{ toggleSshSupportError }}</b>
|
||||
<Button :danger="sshSupportEnabled ? true : null" @click="toggleSshSupport()">{{ sshSupportEnabled ? $t('support.remoteSupport.disableAction') : $t('support.remoteSupport.enableAction') }}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button, Checkbox } from 'pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import ExternalLdap from '../components/ExternalLdap.vue';
|
||||
import ExposedLdap from '../components/ExposedLdap.vue';
|
||||
import OpenIdClients from '../components/OpenIdClients.vue';
|
||||
import UserDirectoryModel from '../models/UserDirectoryModel.js';
|
||||
|
||||
const userDirectoryModel = UserDirectoryModel.create(API_ORIGIN, localStorage.token);
|
||||
|
||||
const editableUserProfiles = ref(false);
|
||||
const mandatory2FA = ref(false);
|
||||
const configBusy = ref(false);
|
||||
|
||||
async function onSubmitConfig() {
|
||||
configBusy.value = true;
|
||||
|
||||
const [error] = await userDirectoryModel.setGlobalProfileConfig({ mandatory2FA: mandatory2FA.value, lockUserProfiles: !editableUserProfiles.value });
|
||||
if (error) console.error(error);
|
||||
|
||||
configBusy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await userDirectoryModel.getGlobalProfileConfig();
|
||||
if (error) return console.error(error);
|
||||
|
||||
editableUserProfiles.value = !result.lockUserProfiles;
|
||||
mandatory2FA.value = result.mandatory2FA;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<Section :title="$t('users.title')">
|
||||
<form role="form" novalidate @submit="onSubmitConfig()" autocomplete="off">
|
||||
<fieldset :disabled="configBusy">
|
||||
<div>
|
||||
<Checkbox v-model="editableUserProfiles" :label="$t('users.settings.allowProfileEditCheckbox')" /><sup><a href="https://docs.cloudron.io/user-directory/#lock-profile" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</div>
|
||||
<div>
|
||||
<Checkbox v-model="mandatory2FA" :label="$t('users.settings.require2FACheckbox')" />
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<Button @click="onSubmitConfig()" :disabled="configBusy" :loading="configBusy">{{ $t('users.settings.saveAction') }}</Button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
<ExternalLdap />
|
||||
<ExposedLdap />
|
||||
<OpenIdClients />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,314 @@
|
||||
<script>
|
||||
|
||||
import { Button, ButtonGroup, Checkbox, Dialog, Dropdown, FormGroup, InputDialog, NumberInput, PasswordInput, TableView, TextInput } from 'pankow';
|
||||
|
||||
import Section from '../components/Section.vue';
|
||||
|
||||
import { createVolumesModel } from '../models/VolumesModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
const volumesModel = createVolumesModel(API_ORIGIN, accessToken);
|
||||
|
||||
export default {
|
||||
name: 'VolumesView',
|
||||
components: {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Section,
|
||||
Checkbox,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormGroup,
|
||||
InputDialog,
|
||||
NumberInput,
|
||||
PasswordInput,
|
||||
TableView,
|
||||
TextInput,
|
||||
},
|
||||
computed: {
|
||||
volumeDialogValid() {
|
||||
const data = this.volumeDialogData;
|
||||
|
||||
if (data.mode === 'new') {
|
||||
if (!data.name) return false;
|
||||
if (!data.mountType) return false;
|
||||
}
|
||||
|
||||
switch (data.mountType) {
|
||||
case 'filesystem':
|
||||
case 'mountpoint':
|
||||
if (!data.hostPath) return false;
|
||||
if (!data.hostPath) return false;
|
||||
break;
|
||||
case 'ext4':
|
||||
case 'xfs':
|
||||
if (!data.diskPath) return false;
|
||||
break;
|
||||
case 'nfs':
|
||||
if (!data.host) return false;
|
||||
if (!data.remoteDirectory) return false;
|
||||
break;
|
||||
case 'sshfs':
|
||||
if (!data.host) return false;
|
||||
if (!data.remoteDirectory) return false;
|
||||
if (!data.post) return false;
|
||||
if (!data.user) return false;
|
||||
if (!data.privateKey) return false;
|
||||
break;
|
||||
case 'cifs':
|
||||
if (!data.host) return false;
|
||||
if (!data.remoteDirectory) return false;
|
||||
if (!data.username) return false;
|
||||
if (!data.password) return false;
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
busy: true,
|
||||
mountTypeOptions: [
|
||||
{ name: 'CIFS', value: 'cifs' },
|
||||
{ name: 'EXT4', value: 'ext4' },
|
||||
{ name: 'Filesystem', value: 'filesystem' },
|
||||
{ name: 'Filesystem (Mountpoint)', value: 'mountpoint' },
|
||||
{ name: 'NFS', value: 'nfs' },
|
||||
{ name: 'SSHFS', value: 'sshfs' },
|
||||
{ name: 'XFS', value: 'xfs' },
|
||||
],
|
||||
columns: {
|
||||
status: {},
|
||||
name: { label: 'Name', sort: true },
|
||||
mountType: { label: 'Type', sort: true },
|
||||
target: { label: 'Target', sort: true },
|
||||
actions: {}
|
||||
},
|
||||
volumes: [],
|
||||
volumeDialogData: {
|
||||
error: null,
|
||||
busy: false,
|
||||
mode: '', // edit or new
|
||||
name: '',
|
||||
// dynamic extra props from openVolumeDialog
|
||||
},
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
this.busy = true;
|
||||
this.volumes = await volumesModel.list();
|
||||
this.busy = false;
|
||||
|
||||
for (const v of this.volumes) {
|
||||
const status = await volumesModel.getStatus(v.id);
|
||||
v.state = status.state;
|
||||
v.message = status.message;
|
||||
}
|
||||
},
|
||||
async openVolumeDialog(volume) {
|
||||
this.volumeDialogData.error = null;
|
||||
this.volumeDialogData.mode = volume ? 'edit' : 'new';
|
||||
this.volumeDialogData.id = volume ? volume.id : '';
|
||||
this.volumeDialogData.name = volume ? volume.name : '';
|
||||
this.volumeDialogData.mountType = volume ? volume.mountType : '';
|
||||
this.volumeDialogData.host = volume ? volume.mountOptions.host : '';
|
||||
this.volumeDialogData.seal = volume ? volume.mountOptions.seal : false;
|
||||
this.volumeDialogData.port = volume ? volume.mountOptions.port : 0;
|
||||
this.volumeDialogData.remoteDir = volume ? volume.mountOptions.remoteDir : '';
|
||||
this.volumeDialogData.username = volume ? volume.mountOptions.username : '';
|
||||
this.volumeDialogData.password = volume ? volume.mountOptions.password : '';
|
||||
this.volumeDialogData.diskPath = volume ? volume.mountOptions.diskPath : '';
|
||||
this.volumeDialogData.hostPath = volume ? volume.mountOptions.hostPath : '';
|
||||
|
||||
let blockDevices = await volumesModel.getBlockDevices();
|
||||
|
||||
// only offer unmounted disks
|
||||
blockDevices = blockDevices.filter(d => !d.mountpoint);
|
||||
|
||||
// amend label for UI
|
||||
blockDevices.forEach(d => d.label = d.path);
|
||||
|
||||
this.volumeDialogData.ext4BlockDevices = blockDevices.filter(d => d.type === 'ext4');
|
||||
this.volumeDialogData.xfsBlockDevices = blockDevices.filter(d => d.type === 'xfs');
|
||||
|
||||
this.$refs.volumeDialog.open();
|
||||
},
|
||||
async submitVolumeDialog() {
|
||||
this.volumeDialogData.busy = true;
|
||||
|
||||
const mountOptions = {
|
||||
host: this.volumeDialogData.host,
|
||||
seal: this.volumeDialogData.seal,
|
||||
port: this.volumeDialogData.port,
|
||||
remoteDir: this.volumeDialogData.remoteDir,
|
||||
username: this.volumeDialogData.username,
|
||||
password: this.volumeDialogData.password,
|
||||
diskPath: this.volumeDialogData.diskPath,
|
||||
hostPath: this.volumeDialogData.hostPath,
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.volumeDialogData.mode === 'new') {
|
||||
await volumesModel.add(this.volumeDialogData.name, this.volumeDialogData.mountType, mountOptions);
|
||||
} else {
|
||||
await volumesModel.update(this.volumeDialogData.id, mountOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
this.volumeDialogData.error = error.body ? error.body.message : 'Internal error';
|
||||
this.volumeDialogData.busy = false;
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.refresh();
|
||||
|
||||
this.$refs.volumeDialog.close();
|
||||
this.volumeDialogData.busy = false;
|
||||
},
|
||||
async onRemove(volume) {
|
||||
const yes = await this.$refs.inputDialog.confirm({
|
||||
message: `Really remove volume ${volume.name}?`,
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: this.$t('volumes.removeVolumeDialog.removeAction'),
|
||||
rejectLabel: this.$t('main.dialog.cancel')
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
await volumesModel.remove(volume.id);
|
||||
await this.refresh();
|
||||
},
|
||||
async remount(volume) {
|
||||
await volumesModel.remount(volume.id);
|
||||
|
||||
const status = await volumesModel.getStatus(volume.id);
|
||||
volume.state = status.state;
|
||||
volume.message = status.message;
|
||||
|
||||
window.pankow.notify('Remount attempt finished');
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.refresh();
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="volumeDialog"
|
||||
:title="volumeDialogData.mode === 'edit' ? $t('volumes.editVolumeDialog.title', { name: volumeDialogData.name }) : $t('volumes.addVolumeDialog.title')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
confirm-style="success"
|
||||
:confirm-active="volumeDialogValid"
|
||||
:confirm-busy="volumeDialogData.busy"
|
||||
@confirm="submitVolumeDialog()"
|
||||
>
|
||||
<form @submit="submitVolumeDialog()" autocomplete="off">
|
||||
<fieldset :disabled="volumeDialogData.busy">
|
||||
<input style="display: none;" type="submit" :disabled="!volumeDialogValid" />
|
||||
|
||||
<p class="has-error" v-show="volumeDialogData.error">{{ volumeDialogData.error }}</p>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mode === 'new'">
|
||||
<label for="volumeName">{{ $t('volumes.name') }}</label>
|
||||
<TextInput id="volumeName" v-model="volumeDialogData.name" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="volumeMountType">{{ $t('volumes.mountType') }}</label>
|
||||
<Dropdown id="volumeMountType" v-model="volumeDialogData.mountType" :options="mountTypeOptions" option-label="name" option-key="value" :disabled="volumeDialogData.mode === 'edit'"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'filesystem' || volumeDialogData.mountType === 'mountpoint'">
|
||||
<label for="volumeHostPath">{{ $t('volumes.localDirectory') }}</label>
|
||||
<TextInput id="volumeHostPath" v-model="volumeDialogData.hostPath" :placeholder="volumeDialogData.mountType === 'filesystem' ? '/srv/shared' : '/mnt/data'" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'ext4' || volumeDialogData.mountType === 'xfs'">
|
||||
<label for="volumeDiskPath">{{ $t('volumes.addVolumeDialog.diskPath') }}</label>
|
||||
<Dropdown id="volumeMountType" v-if="volumeDialogData.mountType === 'ext4'" v-model="volumeDialogData.diskPath" :options="volumeDialogData.ext4BlockDevices" option-label="label" option-key="path" :disabled="volumeDialogData.mode === 'edit'"/>
|
||||
<Dropdown id="volumeMountType" v-if="volumeDialogData.mountType === 'xfs'" v-model="volumeDialogData.diskPath" :options="volumeDialogData.xfsBlockDevices" option-label="label" option-key="path" :disabled="volumeDialogData.mode === 'edit'"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'cifs' || volumeDialogData.mountType === 'nfs' || volumeDialogData.mountType === 'sshfs'">
|
||||
<label for="volumeHost">{{ $t('volumes.addVolumeDialog.server') }}</label>
|
||||
<TextInput v-model="volumeDialogData.host" id="volumeHost"/>
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="volumeDialogData.mountType === 'cifs'" v-model="volumeDialogData.seal" :label="$t('backups.configureBackupStorage.cifsSealSupport')" />
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'sshfs'">
|
||||
<label for="volumePort">{{ $t('volumes.addVolumeDialog.port') }}</label>
|
||||
<NumberInput v-model="volumeDialogData.port" id="volumePort" min="0"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'cifs' || volumeDialogData.mountType === 'nfs' || volumeDialogData.mountType === 'sshfs'">
|
||||
<label for="volumeRemoteDir">{{ $t('volumes.addVolumeDialog.remoteDirectory') }}</label>
|
||||
<TextInput v-model="volumeDialogData.remoteDir" id="volumeRemoteDir" placeholder="/share" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'cifs'">
|
||||
<label for="volumeUsername">{{ $t('volumes.addVolumeDialog.username') }}</label>
|
||||
<TextInput v-model="volumeDialogData.username" id="volumeUsername" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'cifs'">
|
||||
<label for="volumePassword">{{ $t('volumes.addVolumeDialog.password') }}</label>
|
||||
<PasswordInput v-model="volumeDialogData.password" id="volumePassword" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'sshfs'">
|
||||
<label for="volumeUser">{{ $t('volumes.addVolumeDialog.user') }}</label>
|
||||
<TextInput v-model="volumeDialogData.user" id="volumeAddUser" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'sshfs'">
|
||||
<label for="volumePrivateKey">{{ $t('volumes.addVolumeDialog.privateKey') }}</label>
|
||||
<textarea v-model="volumeDialogData.privateKey" id="volumePrivateKey"></textarea>
|
||||
</FormGroup>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('volumes.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="openVolumeDialog()" icon="fa fa-plus">{{ $t('volumes.addVolumeAction') }}</Button>
|
||||
</template>
|
||||
|
||||
<div v-html="$t('volumes.description')"></div>
|
||||
<br/>
|
||||
<TableView :columns="columns" :model="volumes" :busy="busy">
|
||||
<template #target="slotProps">
|
||||
{{ (slotProps.mountType === 'mountpoint' || slotProps.mountType === 'filesystem') ? slotProps.hostPath : (slotProps.mountOptions.host || slotProps.mountOptions.diskPath || slotProps.hostPath) + (slotProps.mountOptions.remoteDir || '') }}
|
||||
</template>
|
||||
<template #status="slotProps">
|
||||
<div style="text-align: center;" v-tooltip="slotProps.message">
|
||||
<i class="fa fa-circle" :style="{ color: slotProps.state === 'active' ? '#27CE65' : '#d9534f' }"></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="slotProps">
|
||||
<div class="table-actions">
|
||||
<ButtonGroup>
|
||||
<Button tool secondary outline small icon="fa fa-sync-alt" v-if="slotProps.mountType === 'sshfs' || slotProps.mountType === 'cifs' || slotProps.mountType === 'nfs' || slotProps.mountType === 'ext4' || slotProps.mountType === 'xfs'" v-tooltip="$t('volumes.remountActionTooltip')" @click="remount(slotProps)"></Button>
|
||||
<Button tool secondary outline small icon="fa fa-pencil-alt" v-if="slotProps.mountType === 'sshfs' || slotProps.mountType === 'cifs' || slotProps.mountType === 'nfs'" v-tooltip="$t('volumes.editActionTooltip')" @click="openVolumeDialog(slotProps)"></Button>
|
||||
<Button tool secondary outline small icon="fas fa-folder" v-tooltip="$t('volumes.openFileManagerActionTooltip')" :href="'/filemanager.html#/home/volume/' + slotProps.id" target="_blank"></Button>
|
||||
</ButtonGroup>
|
||||
<Button tool danger outline small icon="far fa-trash-alt" v-tooltip="$t('volumes.removeVolumeActionTooltip')" @click="onRemove(slotProps)"></Button>
|
||||
</div>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user