add AppInstall view/route

This commit is contained in:
Axolotle 2020-10-06 23:53:56 +02:00
parent 84374cdd53
commit b1b9e65b7b
5 changed files with 304 additions and 17 deletions

View file

@ -0,0 +1,64 @@
import i18n from '@/i18n'
import store from '@/store'
function formatI18nField (field) {
if (typeof field === 'string') return field
const { locale, fallbackLocale } = store.state
return field[locale] || field[fallbackLocale] || field.en
}
export function formatYunoHostArgument (_arg) {
const arg = {
component: undefined,
label: formatI18nField(_arg.ask),
props: { id: _arg.name }
}
// Some apps has `string` as type but expect a select since it has `choices`
if (_arg.choices !== undefined) {
arg.component = 'SelectItem'
arg.props.choices = _arg.choices
// Input
} else if ([undefined, 'string', 'number', 'password', 'email'].includes(_arg.type)) {
arg.component = 'InputItem'
if (![undefined, 'string'].includes(_arg.type)) {
arg.props.type = _arg.type
if (_arg.type === 'password') {
arg.description = i18n.t('good_practices_about_admin_password')
}
}
// Checkbox
} else if (_arg.type === 'boolean') {
arg.component = 'CheckboxItem'
arg.props.value = _arg.default || false
// Special (store related)
} else if (['user', 'domain'].includes(_arg.type)) {
arg.component = 'SelectItem'
arg.link = { name: _arg.type + '-list', text: i18n.t(`manage_${_arg.type}s`) }
arg.props = { ...arg.props, ...store.getters[_arg.type + 'sAsOptions'] }
// Unknown from the specs, try to display it as an input[text]
// FIXME throw an error instead ?
} else {
arg.component = 'InputItem'
}
// Required
arg.props.required = _arg.optional !== true
// Default value
if (_arg.default) {
arg.props.value = _arg.default
}
// Help message
if (_arg.help) {
arg.description = formatI18nField(_arg.help)
}
// Example
if (_arg.example) {
arg.example = _arg.example
if (arg.component === 'InputItem') {
arg.props.placeholder = arg.example
}
}
return arg
}

View file

@ -64,7 +64,7 @@
"confirm_firewall_open": "Are you sure you want to open port {port} (protocol: {protocol}, connection: {connection})",
"confirm_firewall_close": "Are you sure you want to close port {port} (protocol: {protocol}, connection: {connection})",
"confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_install_domain_root": "You will not be able to install any other app on %s. Continue?",
"confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}",
"confirm_app_install": "Are you sure you want to install this application?",
"confirm_install_app_lowquality": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available.",
"confirm_install_app_inprogress": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
@ -140,7 +140,7 @@
"firewall_port_already": "Port {port} is already {state} (protocol: {protocol}; connection: {connection})",
"not_github_link": "Url must be a valid Github link to a repository"
},
"form_input_example": "Example: %s",
"form_input_example": "Example: {example}",
"from_to": "from {0} to {1}",
"good_practices_about_admin_password": "You are now about to define a new admin password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).",
"good_practices_about_user_password": "You are now about to define a new user password. The password should be at least 8 characters - though it is good practice to use longer password (i.e. a passphrase) and/or to use various kind of characters (uppercase, lowercase, digits and special characters).",
@ -191,7 +191,7 @@
"ipv6": "IPv6",
"issues": "{count} issues",
"label": "Label",
"label_for_manifestname": "Label for %s",
"label_for_manifestname": "Label for {name}",
"last_ran": "Last time ran:",
"license": "License",
"loading": "Loading …",

View file

@ -188,6 +188,32 @@ const routes = [
]
}
},
{
name: 'app-install',
path: '/apps/install/:id',
component: () => import(/* webpackChunkName: "views/apps" */ '@/views/app/AppInstall'),
props: true,
meta: {
breadcrumb: [
{ name: 'app-list', trad: 'applications' },
{ name: 'app-catalog', trad: 'catalog' },
{ name: 'app-catalog', trad: 'install_name', param: 'id' }
]
}
},
{
name: 'app-install-custom',
path: '/apps/install-custom/:id',
component: () => import(/* webpackChunkName: "views/apps" */ '@/views/app/AppInstall'),
props: true,
meta: {
breadcrumb: [
{ name: 'app-list', trad: 'applications' },
{ name: 'app-catalog', trad: 'catalog' },
{ name: 'app-catalog', trad: 'install_name', param: 'id' }
]
}
},
/*
SYSTEM UPDATE

View file

@ -49,21 +49,27 @@ body {
display: block;
}
.col-form-label {
@include media-breakpoint-up(xs) {
width: 100%;
// <b-row /> elems with <b-col> inside and eventually .sep inside
.row-line {
&:hover > div :first-child {
background-color: rgba(0, 0, 0, 0.05);
border-radius: 0.2rem;
padding: 0 .5rem;
margin-right: -.5rem;
margin-left: -.5rem;
}
@include media-breakpoint-up(sm) {
flex-basis: 40%;
}
@include media-breakpoint-up(md) {
flex-basis: 30%;
}
@include media-breakpoint-up(lg) {
flex-basis: 25%;
}
@include media-breakpoint-up(xl) {
flex-basis: 20%;
& > div {
align-self: flex-start;
display: flex;
align-items: center;
// display a dashed line between col items
.sep {
height: 0;
border-top: 1px dashed rgba(0, 0, 0, 0.25);
flex-grow: 1;
margin: 0 .75rem;
}
}
}

View file

@ -0,0 +1,191 @@
<template>
<div class="app-install">
<div v-if="infos">
<!-- BASIC INFOS -->
<b-card>
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ $t('infos') }} {{ name }}</h2>
</template>
<b-row
v-for="key in infosKeys" :key="key"
no-gutters class="row-line"
>
<b-col cols="5" md="3" xl="3">
<strong>{{ $t(key) }}</strong>
<span class="sep" />
</b-col>
<b-col>
<span>{{ infos[key] }}</span>
</b-col>
</b-row>
</b-card>
<!-- INSTALL FORM -->
<b-card>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<b-form id="install-form" @submit.prevent="beforeInstall">
<form-item-helper v-bind="form.label" />
<form-item-helper v-for="arg in form.args" :key="arg.name" v-bind="arg" />
<b-form-invalid-feedback id="global-feedback" :state="server.isValid">
{{ this.server.error }}
</b-form-invalid-feedback>
</b-form>
<template v-slot:footer>
<b-button
type="submit" form="install-form"
variant="success" v-t="'install'"
/>
</template>
</b-card>
<!-- CONFIRM INSTALL DOMAIN ROOT MODAL -->
<b-modal
id="confirm-domain-root-modal" ref="confirm-domain-root-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="runInstall" hide-header
:ok-title="$t('install')"
>
{{ $t('confirm_install_domain_root', { domain: confirmDomain }) }}
</b-modal>
</div>
<!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="infos === null" variant="warning" show>
<icon iname="exclamation-triangle" /> {{ $t('app_install_custom_no_manifest') }}
</b-alert>
</div>
</template>
<script>
import api, { objectToParams } from '@/helpers/api'
import { formatYunoHostArgument } from '@/helpers/yunohostArguments'
export default {
name: 'AppInstall',
props: {
id: {
type: String,
required: true
}
},
data () {
return {
name: undefined,
infosKeys: ['id', 'description', 'license', 'version', 'multi_instance'],
infos: undefined,
form: undefined,
server: {
isValid: null,
error: ''
},
confirmDomain: null
}
},
methods: {
getExternalManifest () {
const url = this.id.replace('github.com', 'raw.githubusercontent.com') + 'master/manifest.json'
return fetch(url).then(response => {
if (response.ok) return response.json()
else {
throw Error('No manifest found at ' + url)
}
}).catch(() => {
this.infos = null
})
},
getApiManifest () {
return api.get('appscatalog?full').then(response => response.apps[this.id].manifest)
},
fetchData () {
const isCustom = this.$route.name === 'app-install-custom'
Promise.all([
isCustom ? this.getExternalManifest() : this.getApiManifest(),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (manifest) {
if (manifest.license === undefined || manifest.license === 'free') {
this.infosKeys.splice(2, 1)
}
const desc = manifest.description
if (typeof desc !== 'string') {
manifest.description = desc[this.$i18n.locale] || desc.en
}
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
const infos = {}
for (const key of this.infosKeys) {
infos[key] = manifest[key]
}
this.infos = infos
this.name = manifest.name
this.form = {
label: formatYunoHostArgument({
ask: this.$i18n.t('label_for_manifestname', { name: manifest.name }),
default: manifest.name,
name: 'label'
}),
args: manifest.arguments.install.map(arg => formatYunoHostArgument(arg))
}
},
beforeInstall () {
const path = this.form.args.find(arg => arg.props.id === 'path').props.value
if (path === '/') {
this.confirmDomain = this.form.args.find(arg => arg.props.id === 'domain').props.value
this.$refs['confirm-domain-root-modal'].show()
} else {
this.runInstall()
}
},
runInstall () {
const args = {}
for (const arg of this.form.args) {
if (arg.component === 'CheckboxItem') {
args[arg.props.id] = arg.props.value ? 1 : 0
} else {
args[arg.props.id] = arg.props.value
}
}
const data = {
app: this.id,
label: this.form.label.props.value,
args: objectToParams(args)
}
api.post('apps', data).then(response => {
this.$router.push({ name: 'app-list' })
}).catch(err => {
this.server.isValid = false
this.server.error = err.message
})
}
},
created () {
this.fetchData()
}
}
</script>
<style>
</style>