Files
cloudron-box/dashboard/src/components/AppInstallDialog.vue
T

440 lines
14 KiB
Vue
Raw Normal View History

<script setup>
2025-01-05 22:47:50 +01:00
2025-05-13 18:35:49 +02:00
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
2025-01-05 22:47:50 +01:00
import { marked } from 'marked';
2025-07-10 11:55:11 +02:00
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup } from '@cloudron/pankow';
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
2025-01-06 14:03:29 +01:00
import AccessControl from './AccessControl.vue';
2025-01-06 20:23:39 +01:00
import PortBindings from './PortBindings.vue';
2025-01-06 14:35:14 +01:00
import AppsModel from '../models/AppsModel.js';
import DashboardModel from '../models/DashboardModel.js';
2025-10-07 14:35:47 +02:00
import DomainsModel from '../models/DomainsModel.js';
2025-03-02 16:23:14 +01:00
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
2025-01-06 14:35:14 +01:00
const STEP = Object.freeze({
DETAILS: Symbol('details'),
INSTALL: Symbol('install'),
});
2025-01-31 21:02:48 +01:00
const appsModel = AppsModel.create();
2025-10-07 14:35:47 +02:00
const domainsModel = DomainsModel.create();
2025-01-31 21:02:48 +01:00
const dashboardModel = DashboardModel.create();
2025-01-06 14:35:14 +01:00
2025-05-13 18:35:49 +02:00
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
// reactive
2025-01-06 14:35:14 +01:00
const busy = ref(false);
2025-01-07 12:49:19 +01:00
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 || ''));
2025-01-06 14:35:14 +01:00
const domains = ref([]);
const dashboardDomain = ref('');
2025-01-06 14:03:29 +01:00
const formValid = computed(() => {
2025-01-06 14:35:14 +01:00
if (!domain.value) return false;
2025-06-10 21:42:26 +02:00
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;
}
}
2025-01-06 14:03:29 +01:00
return true;
});
2025-05-13 18:35:49 +02:00
const appMaxCountExceeded = ref(false);
function setStep(newStep) {
2025-05-13 18:35:49 +02:00
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);
}
2025-01-06 14:03:29 +01:00
// form data
const location = ref('');
2025-03-02 16:23:14 +01:00
const accessRestrictionOption = ref(ACL_OPTIONS.ANY);
const accessRestrictionAcl = ref({ users: [], groups: [] });
const domain = ref('');
const domainProvider = ref('');
2025-01-06 20:23:39 +01:00
const tcpPorts = ref({});
const udpPorts = ref({});
const secondaryDomains = ref({});
const upstreamUri = ref('');
2025-10-07 14:35:47 +02:00
const needsOverwriteDns = ref(false);
2025-01-06 14:35:14 +01:00
function onDomainChange() {
const tmp = domains.value.find(d => d.domain === domain.value);
domainProvider.value = tmp ? tmp.provider : '';
}
2025-10-07 14:35:47 +02:00
async function onSubmit(overwriteDns) {
formError.value = {};
busy.value = true;
2025-10-07 14:35:47 +02:00
const checkForDomains = [{
domain: domain.value,
subdomain: location.value,
}];
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].value });
for (const d of checkForDomains) {
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
if (error) {
formError.value.location = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
if (result.needsOverwrite && !overwriteDns) {
busy.value = false;
needsOverwriteDns.value = true;
formError.value.dnsExists = `DNS record for ${d.subdomain}.${d.domain} already exists`;
return;
}
}
2025-01-06 14:35:14 +01:00
const config = {
subdomain: location.value,
domain: domain.value,
accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? null : accessRestrictionAcl.value)
2025-01-06 14:35:14 +01:00
};
2025-10-07 14:35:47 +02:00
if (overwriteDns) config.overwriteDns = true;
2025-03-02 16:23:14 +01:00
if (manifest.value.optionalSso) config.sso = accessRestrictionOption.value !== ACL_OPTIONS.NOSSO;
2025-01-06 14:03:29 +01:00
2025-01-06 20:23:39 +01:00
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;
2025-10-07 14:35:47 +02:00
const finalSecondaryDomains = {}; for (const 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;
2025-04-23 15:32:42 +02:00
const [error, result] = await appsModel.install(manifest.value, config);
2025-01-06 14:03:29 +01:00
2025-01-06 14:35:14 +01:00
if (!error) {
dialog.value.close();
2025-04-23 15:32:42 +02:00
localStorage['confirmPostInstall_' + result.id] = true;
return window.location.href = '/#/apps';
2025-01-06 14:35:14 +01:00
}
busy.value = false;
if (error.status === 409 && error.body.message.indexOf('port') !== -1) {
const match = error.body.message.match(/.*port.(.*)/);
2025-01-07 12:49:19 +01:00
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;
2025-01-07 12:49:19 +01:00
} else {
console.error('Failed to install:', error);
}
2025-01-06 14:03:29 +01:00
}
2025-01-10 16:03:52 +01:00
function onClose() {
setTimeout(() => step.value = STEP.DETAILS, 2000);
}
2025-01-06 14:35:14 +01:00
onMounted(async () => {
const [error, result] = await dashboardModel.config();
2025-01-27 22:20:26 +01:00
if (error) return console.error(error);
dashboardDomain.value = result.adminDomain;
2025-01-06 14:35:14 +01:00
});
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;
2025-01-06 21:03:29 +01:00
step.value = STEP.DETAILS;
app.value = a;
2025-05-13 18:35:49 +02:00
appMaxCountExceeded.value = appCountExceeded;
manifest.value = a.manifest;
location.value = '';
accessRestrictionOption.value = ACL_OPTIONS.ANY;
accessRestrictionAcl.value = { users: [], groups: [] };
domainProvider.value = '';
upstreamUri.value = '';
2025-10-07 14:35:47 +02:00
needsOverwriteDns.value = '';
2025-09-30 16:01:11 +02:00
domainList.forEach(d => {
2025-09-30 19:05:23 +02:00
d.label = '.' + d.domain;
2025-09-30 16:01:11 +02:00
});
domains.value = domainList;
// preselect with dashboard domain
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).domain;
2025-01-06 20:23:39 +01:00
tcpPorts.value = a.manifest.tcpPorts;
udpPorts.value = a.manifest.udpPorts;
2025-03-03 21:24:27 +01:00
// ensure we have value property
for (const p in tcpPorts.value) {
tcpPorts.value[p].value = tcpPorts.value[p].value || tcpPorts.value[p].defaultValue;
2025-08-01 12:20:45 +02:00
tcpPorts.value[p].enabled = tcpPorts.value[p].enabledByDefault ?? true;
2025-03-03 21:24:27 +01:00
}
for (const p in udpPorts.value) {
udpPorts.value[p].value = udpPorts.value[p].value || udpPorts.value[p].defaultValue;
2025-08-01 12:20:45 +02:00
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
2025-03-03 21:24:27 +01:00
}
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();
},
});
2025-01-05 22:47:50 +01:00
</script>
<template>
2025-10-05 18:14:30 +02:00
<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 }">
2025-03-02 17:22:46 +01:00
<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>
2025-09-12 19:18:59 +02:00
<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>
2025-09-12 19:18:59 +02:00
<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">
2025-10-07 14:35:47 +02:00
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<div class="error-label" v-if="formError.dnsExists">{{ formError.dnsExists }}</div>
2025-10-07 14:35:47 +02:00
<form @submit.prevent="onSubmit(false)" 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>
2025-03-01 19:24:28 +01:00
<InputGroup>
<TextInput id="location" ref="locationInput" v-model="location" style="flex-grow: 1"/>
2025-09-30 16:01:11 +02:00
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10"/>
2025-03-01 19:24:28 +01:00
</InputGroup>
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
2025-10-07 14:35:47 +02:00
<div class="error-label" 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>
2025-03-01 19:24:28 +01:00
<InputGroup>
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
2025-09-30 16:01:11 +02:00
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" />
2025-03-01 19:24:28 +01:00
</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"/>
2025-03-02 16:23:14 +01:00
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest"/>
<div class="bottom-button-bar">
2025-10-07 14:35:47 +02:00
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }}</Button>
</div>
</fieldset>
</form>
</div>
</Transition>
</div>
</Dialog>
</template>
2025-01-05 22:47:50 +01:00
<style scoped>
.app-install-dialog-body {
2025-01-05 23:35:01 +01:00
max-width: 100%;
max-height: 100%;
transition: all 0.25s;
2025-01-10 16:03:52 +01:00
}
.step-detail {
2025-10-13 14:34:37 +02:00
width: 800px;
2025-01-10 16:03:52 +01:00
}
.step-install {
width: 480px;
2025-01-05 23:35:01 +01:00
}
2025-03-02 17:22:46 +01:00
.app-install-header {
2025-01-05 22:47:50 +01:00
display: flex;
align-items: center;
2025-01-05 23:35:01 +01:00
justify-content: space-between;
margin-bottom: 10px;
2025-01-05 22:47:50 +01:00
}
2025-01-05 23:35:01 +01:00
.summary {
2025-01-05 22:47:50 +01:00
display: flex;
flex-direction: column;
2025-01-05 23:35:01 +01:00
font-size: 14px;
overflow: hidden;
2025-01-05 22:47:50 +01:00
}
.summary > div {
text-wrap: nowrap;
}
2025-01-05 22:47:50 +01:00
.title {
2025-01-05 23:35:01 +01:00
font-size: 32px;
2025-01-05 22:47:50 +01:00
margin-bottom: 10px;
display: flex;
align-items: center;
2025-01-05 22:47:50 +01:00
}
.icon {
2025-01-05 23:35:01 +01:00
width: 128px;
height: 128px;
object-fit: contain;
2025-01-05 22:47:50 +01:00
margin-right: 20px;
}
.description {
2025-01-05 23:35:01 +01:00
margin: 0 4px;
}
.screenshots-container {
position: relative;
overflow: hidden;
}
2025-01-05 23:35:01 +01:00
.screenshots {
margin: 10px 4px;
2025-01-05 22:47:50 +01:00
display: flex;
2025-01-05 23:35:01 +01:00
gap: 20px;
width: 100%;
padding: 10px 0;
scroll-snap-type: x mandatory;
2025-01-05 23:35:01 +01:00
overflow: auto;
2025-07-10 10:20:54 +02:00
scrollbar-width: none
2025-01-05 23:35:01 +01:00
}
.screenshot {
display: inline-block;
border-radius: var(--pankow-border-radius);
height: 300px;
2025-07-10 10:25:49 +02:00
object-fit: contain;
scroll-snap-align: center;
scroll-snap-stop: always;
2025-01-05 22:47:50 +01:00
}
.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;
}
2025-01-05 22:47:50 +01:00
</style>