Add applink dialog in apps view
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
<template>
|
||||
<Dialog ref="applinkDialog"
|
||||
:title="$t('app.editApplinkDialog.title')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
confirm-style="success"
|
||||
:confirm-active="isValid"
|
||||
:confirm-busy="busy"
|
||||
@confirm="submit()"
|
||||
>
|
||||
<form @submit="submit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!isValid" />
|
||||
|
||||
<p class="has-error" v-show="error.generic">{{ error.generic }}</p>
|
||||
|
||||
<FormGroup :class="{ 'has-error': error.upstreamUri }">
|
||||
<label for="applinkUpstreamUri">{{ $t('app.applinks.upstreamUri') }}</label>
|
||||
<TextInput id="applinkUpstreamUri" v-model="upstreamUri" required />
|
||||
<span class="text-danger" v-show="error.upstreamUri">{{ error.upstreamUri }}</span>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="applinkLabel">{{ $t('app.applinks.label') }}</label>
|
||||
<TextInput id="applinkLabel" v-model="label" />
|
||||
</FormGroup>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
|
||||
</div>
|
||||
<div id="previewIcon" class="app-custom-icon" @click="showCustomIconSelector()">
|
||||
<img :src="iconSrc" />
|
||||
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
|
||||
</div>
|
||||
<span style="cursor: pointer;" @click="resetCustomIcon()">{{ $t('app.applinks.clearIconAction') }}</span> - <span class="text-small">{{ $t('app.applinks.clearIconDescription') }}</span>
|
||||
<input type="file" ref="iconFileInput" style="display: none" accept="image/png" @change="onIconFileInputChanged($event)"/>
|
||||
</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="applinkTags">{{ $t('app.display.tags') }}</label>
|
||||
<TagInput id="applinkTags" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<Radiobutton v-model="accessRestrictionOption" value="any" :label="$t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" value="groups" :label="$t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
<!-- <span class="label label-danger"v-show="accessRestrictionOption === 'groups' && !isAccessRestrictionValid(applinkDialogData)">{{ $t('appstore.installDialog.errorUserManagementSelectAtLeastOne') }}</span> -->
|
||||
</FormGroup>
|
||||
|
||||
<div v-if="accessRestrictionOption === 'groups'">
|
||||
<div style="margin-left: 20px; display: flex;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-label="username" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-label="name" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import { Dialog, FormGroup, MultiSelect, Radiobutton, TagInput, TextInput } from 'pankow';
|
||||
|
||||
import ApplinksModel from '../models/ApplinksModel.js';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
const accessToken = localStorage.token;
|
||||
|
||||
const applinksModel = ApplinksModel.create(API_ORIGIN, accessToken);
|
||||
const usersModel = UsersModel.create(API_ORIGIN, accessToken);
|
||||
const groupsModel = GroupsModel.create(API_ORIGIN, accessToken);
|
||||
|
||||
export default {
|
||||
name: 'ApplinkDialog',
|
||||
components: {
|
||||
Dialog,
|
||||
FormGroup,
|
||||
MultiSelect,
|
||||
Radiobutton,
|
||||
TagInput,
|
||||
TextInput,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
API_ORIGIN,
|
||||
users: [],
|
||||
groups: [],
|
||||
busy: false,
|
||||
error: {},
|
||||
mode: '',
|
||||
id: '',
|
||||
upstreamUri: '',
|
||||
label: '',
|
||||
tags: [],
|
||||
icon: {},
|
||||
iconUrl: '',
|
||||
accessRestrictionOption: '',
|
||||
accessRestriction: {
|
||||
users: [],
|
||||
groups: [],
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isValid() {
|
||||
if (!this.label) return false;
|
||||
if (!this.upstreamUri) return false;
|
||||
|
||||
return true;
|
||||
},
|
||||
iconSrc() {
|
||||
if (this.icon.data === '__original__') { // user clicked reset
|
||||
// https://png-pixel.com/ white pixel placeholder
|
||||
return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=';
|
||||
} else if (this.icon.data) { // user uploaded icon
|
||||
return this.icon.data;
|
||||
} else if (this.iconUrl) { // current icon
|
||||
return API_ORIGIN + this.iconUrl;
|
||||
} else {
|
||||
return API_ORIGIN + 'img/appicon_fallback.png';
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetCustomIcon() {
|
||||
this.icon.data = '__original__';
|
||||
},
|
||||
showCustomIconSelector() {
|
||||
this.$refs.iconFileInput.click();
|
||||
},
|
||||
onIconFileInputChanged(event) {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => this.icon.data = fr.result;
|
||||
fr.readAsDataURL(event.target.files[0]);
|
||||
},
|
||||
async open(applink) {
|
||||
this.mode = applink ? 'edit' : 'new';
|
||||
this.id = applink ? applink.id : '';
|
||||
this.upstreamUri = applink ? applink.upstreamUri : '';
|
||||
this.label = applink ? applink.label : '';
|
||||
this.iconUrl = applink ? applink.iconUrl : '';
|
||||
this.tags = applink ? applink.tags : [];
|
||||
this.accessRestrictionOption = applink && applink.accessRestriction ? 'groups' : 'any';
|
||||
this.accessRestriction = applink && applink.accessRestriction ? applink.accessRestriction : { users: [], groups: [] };
|
||||
|
||||
// fetch users and groups
|
||||
this.users = await usersModel.list();
|
||||
this.groups = await groupsModel.list();
|
||||
|
||||
this.$refs.applinkDialog.open();
|
||||
},
|
||||
async submit() {
|
||||
this.busy = true;
|
||||
|
||||
const data = {
|
||||
label: this.label,
|
||||
upstreamUri: this.upstreamUri,
|
||||
tags: this.tags,
|
||||
};
|
||||
|
||||
data.accessRestriction = null;
|
||||
if (this.accessRestrictionOption === 'groups') {
|
||||
data.accessRestriction = { users: [], groups: [] };
|
||||
data.accessRestriction.users = this.accessRestriction.users.map(function (u) { return u.id; });
|
||||
data.accessRestriction.groups = this.accessRestriction.groups.map(function (g) { return g.id; });
|
||||
}
|
||||
|
||||
if (this.icon.data === '__original__') { // user reset the icon
|
||||
data.icon = '';
|
||||
} else if (this.icon.data) { // user loaded custom icon
|
||||
data.icon = this.icon.data.replace(/^data:image\/[a-z]+;base64,/, '');
|
||||
}
|
||||
|
||||
await applinksModel.update(this.id, data);
|
||||
|
||||
this.busy = false;
|
||||
|
||||
this.$refs.applinkDialog.close();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<ApplinkDialog ref="applinkDialog" />
|
||||
|
||||
<h1 class="section-header">
|
||||
{{ $t('apps.title') }}
|
||||
<div>
|
||||
@@ -11,7 +13,7 @@
|
||||
<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">
|
||||
<a class="config" v-show="isOperator(app)" :href="`#/app/${app.id}/info`" @click="openAppInfo(app)"><Icon icon="fa-solid fa-cog" /></a>
|
||||
<div class="config" v-show="isOperator(app)" @click.prevent="openAppEdit(app)"><Icon icon="fa-solid fa-cog" /></div>
|
||||
<img :src="API_ORIGIN + app.iconUrl"/>
|
||||
<div class="grid-item-label">{{ app.label || app.subdomain || app.fqdn }}</div>
|
||||
<div class="apps-progress" v-show="isOperator(app)">
|
||||
@@ -63,7 +65,7 @@
|
||||
<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 :href="`#/app/${slotProps.id}/info`" icon="fa-solid fa-cog"></Button>
|
||||
<Button tool @click="openAppEdit(slotProps)" icon="fa-solid fa-cog"></Button>
|
||||
</div>
|
||||
</template>
|
||||
</TableView>
|
||||
@@ -89,15 +91,18 @@
|
||||
|
||||
<script>
|
||||
|
||||
import { Button, ButtonGroup, Icon, ProgressBar, TableView, TextInput } from 'pankow';
|
||||
import { Button, ButtonGroup, 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 ApplinkDialog from './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 applinksModel = ApplinksModel.create(API_ORIGIN, accessToken);
|
||||
|
||||
const VIEW_TYPE = {
|
||||
LIST: 'list',
|
||||
@@ -109,10 +114,10 @@ let refreshInterval;
|
||||
export default {
|
||||
name: 'AppsView',
|
||||
components: {
|
||||
ApplinkDialog,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Icon,
|
||||
ProgressBar,
|
||||
TableView,
|
||||
TextInput,
|
||||
},
|
||||
@@ -147,7 +152,7 @@ export default {
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -155,14 +160,15 @@ export default {
|
||||
return this.apps.filter(a => {
|
||||
return a.fqdn.indexOf(this.filter) !== -1;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
installationStateLabel: AppsModel.installationStateLabel,
|
||||
installationActive: AppsModel.installationActive,
|
||||
appProgressMessage: AppsModel.appProgressMessage,
|
||||
openAppInfo(app) {
|
||||
window.location.href = `#/app/${app.id}/info`;
|
||||
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() {
|
||||
@@ -193,7 +199,24 @@ export default {
|
||||
return app.accessLevel === 'operator' || app.accessLevel === 'admin';
|
||||
},
|
||||
async refreshApps() {
|
||||
this.apps = await appsModel.list();
|
||||
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;
|
||||
},
|
||||
toggleView() {
|
||||
this.viewType = this.viewType === VIEW_TYPE.LIST ? VIEW_TYPE.GRID : VIEW_TYPE.LIST;
|
||||
@@ -201,7 +224,8 @@ export default {
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.apps = await appsModel.list();
|
||||
await this.refreshApps();
|
||||
|
||||
this.ready = true;
|
||||
|
||||
refreshInterval = setInterval(this.refreshApps, 5000);
|
||||
|
||||
@@ -15,6 +15,10 @@ import AppsView from './AppsView.vue';
|
||||
import SupportView from './SupportView.vue';
|
||||
import VolumesView from './VolumesView.vue';
|
||||
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const API_ORIGIN = import.meta.env.VITE_API_ORIGIN ? import.meta.env.VITE_API_ORIGIN : window.location.origin;
|
||||
|
||||
const VIEWS = {
|
||||
APPS: 'apps',
|
||||
SUPPORT: 'support',
|
||||
@@ -33,6 +37,7 @@ export default {
|
||||
return {
|
||||
VIEWS,
|
||||
accessToken: localStorage.token,
|
||||
profile: {},
|
||||
view: ''
|
||||
};
|
||||
},
|
||||
@@ -44,6 +49,9 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
const profileModel = ProfileModel.create(API_ORIGIN, localStorage.token);
|
||||
this.profile = await profileModel.get();
|
||||
|
||||
const that = this;
|
||||
function onHashChange() {
|
||||
const view = location.hash.slice(2);
|
||||
|
||||
Reference in New Issue
Block a user