410 lines
13 KiB
Vue
410 lines
13 KiB
Vue
<script setup>
|
|
|
|
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
|
|
import { marked } from 'marked';
|
|
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup } from '@cloudron/pankow';
|
|
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
|
|
import AccessControl from './AccessControl.vue';
|
|
import PortBindings from './PortBindings.vue';
|
|
import AppsModel from '../models/AppsModel.js';
|
|
import DashboardModel from '../models/DashboardModel.js';
|
|
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
|
|
|
const STEP = Object.freeze({
|
|
DETAILS: Symbol('details'),
|
|
INSTALL: Symbol('install'),
|
|
});
|
|
|
|
const appsModel = AppsModel.create();
|
|
const dashboardModel = DashboardModel.create();
|
|
|
|
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
|
|
|
// reactive
|
|
const busy = ref(false);
|
|
const formError = ref({});
|
|
const app = ref({});
|
|
const manifest = ref({});
|
|
const step = ref(STEP.DETAILS);
|
|
const dialog = useTemplateRef('dialogHandle');
|
|
const locationInput = useTemplateRef('locationInput');
|
|
const description = computed(() => marked.parse(manifest.value.description || ''));
|
|
const domains = ref([]);
|
|
const dashboardDomain = ref('');
|
|
|
|
const formValid = computed(() => {
|
|
if (!domain.value) return false;
|
|
|
|
if (location.value && !isValidDomain(location.value + '.' + domain.value)) return false;
|
|
|
|
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
|
|
|
|
if (manifest.value.id === PROXY_APP_ID) {
|
|
try {
|
|
new URL(upstreamUri.value);
|
|
// eslint-disable-next-line no-unused-vars
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
const appMaxCountExceeded = ref(false);
|
|
|
|
function setStep(newStep) {
|
|
if (newStep === STEP.INSTALL && appMaxCountExceeded.value) {
|
|
dialog.value.close();
|
|
subscriptionRequiredDialog.value.open();
|
|
return;
|
|
}
|
|
|
|
step.value = newStep;
|
|
if (newStep === STEP.INSTALL) setTimeout(() => locationInput.value.$el.focus(), 500);
|
|
}
|
|
|
|
// form data
|
|
const location = ref('');
|
|
const accessRestrictionOption = ref(ACL_OPTIONS.ANY);
|
|
const accessRestrictionAcl = ref({ users: [], groups: [] });
|
|
const domain = ref('');
|
|
const domainProvider = ref('');
|
|
const tcpPorts = ref({});
|
|
const udpPorts = ref({});
|
|
const secondaryDomains = ref({});
|
|
const upstreamUri = ref('');
|
|
|
|
function onDomainChange() {
|
|
const tmp = domains.value.find(d => d.domain === domain.value);
|
|
domainProvider.value = tmp ? tmp.provider : '';
|
|
}
|
|
|
|
async function submit() {
|
|
formError.value = {};
|
|
busy.value = true;
|
|
|
|
const config = {
|
|
subdomain: location.value,
|
|
domain: domain.value,
|
|
accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? null : accessRestrictionAcl.value)
|
|
};
|
|
|
|
if (manifest.value.optionalSso) config.sso = accessRestrictionOption.value !== ACL_OPTIONS.NOSSO;
|
|
|
|
const finalPorts = {};
|
|
for (const p in tcpPorts.value) {
|
|
const port = tcpPorts.value[p];
|
|
if (port.enabled) finalPorts[p] = port.value;
|
|
}
|
|
for (const p in udpPorts.value) {
|
|
const port = udpPorts.value[p];
|
|
if (port.enabled) finalPorts[p] = port.value;
|
|
}
|
|
config.ports = finalPorts;
|
|
|
|
const finalSecondaryDomains = {};
|
|
for (var p in secondaryDomains.value) {
|
|
finalSecondaryDomains[p] = {
|
|
subdomain: secondaryDomains.value[p].value,
|
|
domain: secondaryDomains.value[p].domain
|
|
};
|
|
}
|
|
config.secondaryDomains = finalSecondaryDomains;
|
|
|
|
if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value;
|
|
|
|
const [error, result] = await appsModel.install(manifest.value, config);
|
|
|
|
if (!error) {
|
|
dialog.value.close();
|
|
localStorage['confirmPostInstall_' + result.id] = true;
|
|
return window.location.href = '/#/apps';
|
|
}
|
|
|
|
busy.value = false;
|
|
if (error.status === 409 && error.body.message.indexOf('port') !== -1) {
|
|
const match = error.body.message.match(/.*port.(.*)/);
|
|
formError.value.port = match ? parseInt(match[1]) : null;
|
|
} else if (error.status === 409 && error.body.message.indexOf('primary location') !== -1) {
|
|
formError.value.location = error.body.message;
|
|
} else if (error.status === 412) {
|
|
formError.value.generic = error.body.message;
|
|
} else {
|
|
console.error('Failed to install:', error);
|
|
}
|
|
}
|
|
|
|
function onClose() {
|
|
setTimeout(() => step.value = STEP.DETAILS, 2000);
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const [error, result] = await dashboardModel.config();
|
|
if (error) return console.error(error);
|
|
|
|
dashboardDomain.value = result.adminDomain;
|
|
});
|
|
|
|
const screenshotsContainer = useTemplateRef('screenshotsContainer');
|
|
let currentScreenshotPos = 0;
|
|
function onScreenshotPrev() {
|
|
if (currentScreenshotPos <= 0) currentScreenshotPos = manifest.value.mediaLinks.length-1;
|
|
else --currentScreenshotPos;
|
|
|
|
const elem = screenshotsContainer.value.children.item(currentScreenshotPos);
|
|
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
|
}
|
|
|
|
function onScreenshotNext() {
|
|
if (currentScreenshotPos >= manifest.value.mediaLinks.length-1) currentScreenshotPos = 0;
|
|
else ++currentScreenshotPos;
|
|
|
|
const elem = screenshotsContainer.value.children.item(currentScreenshotPos);
|
|
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
|
}
|
|
|
|
defineExpose({
|
|
open: async function(a, appCountExceeded, domainList) {
|
|
busy.value = false;
|
|
step.value = STEP.DETAILS;
|
|
app.value = a;
|
|
appMaxCountExceeded.value = appCountExceeded;
|
|
manifest.value = a.manifest;
|
|
location.value = '';
|
|
accessRestrictionOption.value = ACL_OPTIONS.ANY;
|
|
accessRestrictionAcl.value = { users: [], groups: [] };
|
|
domainProvider.value = '';
|
|
upstreamUri.value = '';
|
|
|
|
domainList.forEach(d => {
|
|
d.label = '.' + d.domain;
|
|
});
|
|
|
|
domains.value = domainList;
|
|
|
|
// preselect with dashboard domain
|
|
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).domain;
|
|
|
|
tcpPorts.value = a.manifest.tcpPorts;
|
|
udpPorts.value = a.manifest.udpPorts;
|
|
|
|
// ensure we have value property
|
|
for (const p in tcpPorts.value) {
|
|
tcpPorts.value[p].value = tcpPorts.value[p].value || tcpPorts.value[p].defaultValue;
|
|
tcpPorts.value[p].enabled = tcpPorts.value[p].enabledByDefault ?? true;
|
|
}
|
|
for (const p in udpPorts.value) {
|
|
udpPorts.value[p].value = udpPorts.value[p].value || udpPorts.value[p].defaultValue;
|
|
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
|
|
}
|
|
|
|
secondaryDomains.value = a.manifest.httpPorts;
|
|
for (const p in secondaryDomains.value) {
|
|
const port = secondaryDomains.value[p];
|
|
port.value = port.defaultValue;
|
|
port.domain = domains.value[0].domain;
|
|
}
|
|
|
|
currentScreenshotPos = 0;
|
|
|
|
dialog.value.open();
|
|
},
|
|
close() {
|
|
dialog.value.close();
|
|
},
|
|
});
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog ref="dialogHandle" @close="onClose()" :show-x="true" style="width: unset; min-width: min(450px, 95%)">
|
|
<div class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
|
<div class="app-install-header">
|
|
<div class="summary" v-if="app.manifest">
|
|
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="app.iconUrl" />{{ manifest.title }}</div>
|
|
<div>{{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}</div>
|
|
<div>{{ manifest.title }} Version {{ app.manifest.upstreamVersion }}</div>
|
|
<div><a :href="manifest.website" target="_blank">{{ manifest.website }}</a></div>
|
|
<div>{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}</div>
|
|
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}</div>
|
|
</div>
|
|
<img class="icon pankow-no-mobile" :src="app.iconUrl" />
|
|
</div>
|
|
<Transition name="slide-left" mode="out-in">
|
|
<div v-if="step === STEP.DETAILS">
|
|
<Button @click="setStep(STEP.INSTALL)" icon="fa-solid fa-circle-down">Install {{ manifest.title }}</Button>
|
|
<div class="screenshots-container">
|
|
<div class="screenshots" ref="screenshotsContainer">
|
|
<img class="screenshot" v-for="image in manifest.mediaLinks" :key="image" :src="image"/>
|
|
</div>
|
|
<div class="screenshots-action screenshots-prev" @click="onScreenshotPrev" v-if="manifest.mediaLinks && manifest.mediaLinks.length > 1"><i class="fa fa-chevron-left"></i></div>
|
|
<div class="screenshots-action screenshots-next" @click="onScreenshotNext" v-if="manifest.mediaLinks && manifest.mediaLinks.length > 1"><i class="fa fa-chevron-right"></i></div>
|
|
</div>
|
|
<div class="description" v-html="description"></div>
|
|
</div>
|
|
<div v-else-if="step === STEP.INSTALL">
|
|
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
|
|
|
<form @submit.prevent="submit()" autocomplete="off">
|
|
<fieldset :disabled="busy">
|
|
<input style="display: none;" type="submit" :disabled="!formValid" />
|
|
|
|
<FormGroup :class="{ 'has-error': formError.location }">
|
|
<label for="location">{{ $t('appstore.installDialog.location') }}</label>
|
|
<InputGroup>
|
|
<TextInput id="location" ref="locationInput" v-model="location" style="flex-grow: 1"/>
|
|
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10"/>
|
|
</InputGroup>
|
|
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
|
|
<div class="text-danger" v-if="formError.location">{{ formError.location }}</div>
|
|
</FormGroup>
|
|
|
|
<FormGroup v-for="(port, key) in secondaryDomains" :key="key">
|
|
<label :for="'secondaryDomainInput' + key">{{ port.title }}</label>
|
|
<small>{{ port.description }}</small>
|
|
<InputGroup>
|
|
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
|
|
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" />
|
|
</InputGroup>
|
|
</FormGroup>
|
|
|
|
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-show="manifest.id === PROXY_APP_ID">
|
|
<label for="upstreamUri">Upstream URI</label>
|
|
<TextInput id="upstreamUri" v-model="upstreamUri" />
|
|
</FormGroup>
|
|
|
|
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
|
|
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest"/>
|
|
|
|
<div class="bottom-button-bar">
|
|
<Button @click="submit" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }}</Button>
|
|
</div>
|
|
</fieldset>
|
|
</form>
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
.app-install-dialog-body {
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
transition: all 0.25s;
|
|
}
|
|
|
|
.step-detail {
|
|
width: 960px;
|
|
}
|
|
|
|
.step-install {
|
|
width: 480px;
|
|
}
|
|
|
|
.app-install-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.summary {
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 14px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.summary > div {
|
|
text-wrap: nowrap;
|
|
}
|
|
|
|
.title {
|
|
font-size: 32px;
|
|
margin-bottom: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.icon {
|
|
width: 128px;
|
|
height: 128px;
|
|
object-fit: contain;
|
|
margin-right: 20px;
|
|
}
|
|
|
|
.description {
|
|
margin: 0 4px;
|
|
}
|
|
|
|
.screenshots-container {
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.screenshots {
|
|
margin: 10px 4px;
|
|
display: flex;
|
|
gap: 20px;
|
|
width: 100%;
|
|
padding: 10px 0;
|
|
scroll-snap-type: x mandatory;
|
|
overflow: auto;
|
|
scrollbar-width: none
|
|
}
|
|
|
|
.screenshot {
|
|
display: inline-block;
|
|
border-radius: var(--pankow-border-radius);
|
|
height: 300px;
|
|
object-fit: contain;
|
|
scroll-snap-align: center;
|
|
scroll-snap-stop: always;
|
|
}
|
|
|
|
.screenshots-action {
|
|
color: white;
|
|
position: absolute;
|
|
top: calc(50% - 40px);
|
|
cursor: pointer;
|
|
margin: 20px;
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 40px;
|
|
padding: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transition: all 200ms;
|
|
background-color: rgba(128,128,128,0.5);
|
|
}
|
|
|
|
.screenshots-container:hover .screenshots-action {
|
|
opacity: 1;
|
|
}
|
|
|
|
.screenshots-action:hover {
|
|
background-color: rgba(128,128,128,0.8);
|
|
}
|
|
|
|
.screenshots-prev {
|
|
left: 0;
|
|
}
|
|
|
|
.screenshots-next {
|
|
right: 0;
|
|
}
|
|
|
|
.bottom-button-bar {
|
|
display: flex;
|
|
justify-content: right;
|
|
margin-top: 20px;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
</style>
|