diff --git a/README.md b/README.md index 9a3c6f67..af03256e 100644 --- a/README.md +++ b/README.md @@ -1,214 +1,186 @@ # Customer Self-Care Web UI -## Development -Please follow these steps to set up a development environment for ngcp-csc-ui. +## Development Quick Start for internal developers -### Node and npm +* Check if [internal development server](https://dev-web-trunk.mgm.sipwise.com/) is running +* [Install yarn](https://yarnpkg.com/getting-started/install) on your system +* Run development environment -First make sure you have Node.js and npm installed, and then install and build the app. + `yarn run dev:docker dev-web-trunk.mgm.sipwise.com` -1. Install latest Node.js v6 and npm v3. It's recommended to [install and use nvm](https://github.com/creationix/nvm) to make sure you're using the right versions -1. Clone the ngcp-csc-ui repo locally -1. Run npm install +## Technology - `npm install` +### Vue.js and Quasar -1. Build the app for dev +After a test phase with Sencha Ext JS we decided to use +[Vue.js](https://vuejs.org) in combination with the [Quasar Framework](https://quasar.dev). - `npm run dev-build` +### Developers basics -### Vagrant +We highly recommend the following courses to understand the +principles and ultimately the code: -To run this app you also need to have a [vagrant-ngcp](https://www.sipwise.org/doc/mr4.1.1/spce/ar01s04.html#_vagrant_box_for_virtualbox) environment up and running. +* [Intro to Vue 2](https://www.vuemastery.com/courses/intro-to-vue-js/vue-instance) +* [Real World Vue 2](https://www.vuemastery.com/courses/real-world-vue-js/real-world-intro) +* [Mastering Vuex](https://www.vuemastery.com/courses/mastering-vuex/success-error-notifications) -1. Go to your local vagrant-ngcp folder, for example +In addition, we also recommend the following Quasar Framework tutorials: - `cd ~/Sipwise/mr/vagrant-ngcp` +* [Quasar Video Tutorials](https://quasar.dev/video-tutorials) -1. Spin up a new vagrant box +## Project Guide - `./v n pro1` +### Add a new page -1. SSH into vagrant box and become root +In order to add a new page you need to go along the following steps: - ` - ./v s pro1 - sudo -s - ` +* Create a new page component using the following naming pattern -1. Edit config.yml + `src/pages/CscPageNewFeature.vue` - `vim /etc/ngcp-config/config.yml` +* Create a route and add it to the route file -1. Enable csc by finding the the "http_csc:" section of the file, and setting "csc_js_enable:" to yes + `src/router/routes.js` - `csc_js_enable: yes` +```javascript +{ + path: '/user/new-feature', + component: CscPageNewFeature, + meta: { + title: i18n.t('pages.newFeature.title'), + subtitle: i18n.t('navigation.newFeature.subTitle') + } +} +``` -1. Run ngcpcfg apply +* Add new feature to the main menu - `ngcpcfg apply 'Enable http_csc'` - -1. Navigate into the vagrant shared local folder (configured via custom_config file in local vagrant-ngcp/users.d/ folder) and execute dev.sh script to set up symlink between local built files and vagrant files served via nginx - - ` - cd /usr/local/devel/ngcp-csc-ui/ - ./dev.sh - ` - -You can now access ngcp-csc-ui in browser by using the url provided by the vagrant script. - -### Enable Call Feature - -To enable use of the call feature, you must first both enable it in ngcp-config and change some options in ngcp-panel. - -1. Edit config.yml - - `vim /etc/ngcp-config/config.yml` - -1. Enable rtcengine by finding the "rtcengine:" section of the file, and setting "enable:" to yes - - ` - rtcengine: - enable: yes - ` - -1. Run ngcpcfg apply - - `ngcpcfg apply 'Enable rtcengine'` - -1. Log in to ngcp-panel with administrator credentials -1. Go to "Settings > Resellers"" -1. Look for reseller row with "default" written in "Name" column, and click "Edit" for that row -1. Check "Enable rtc" checkbox and save -1. Go to "Settings > Domains" -1. Look domain row with domain linked to bound to kamailio in the "Domain" column (usually the bottom-most one for vagrant-ngcp environments), and click "Preferences" for that row -1. Go to "NAT and Media Flow Control" section -1. Set "use_rtpproxy" to "Always with rtpproxy as additional ICE candidate" and "transport_protocol" to "UDP/TLS/RTP/SAVPF (encrypted SRTP using DTLS-SRTP with RTCP feedback)" - -Before making calls, also make sure that you give the caller and callee E164 Number values: -1. Go to "Settings > Subscriber", find the subscriber and click "Details" -1. Go to "Master Data" section and click "Edit" -1. Input E164 number (for example 43 12 3456) and click save - -Troubleshooting tips for when the call feature does not want to enable: -1. Log out and in again of CSC -1. Check that rtcengine is active in your vagrant box, by executing as root: - - `ngcp-service summary` - -1. If inactive, restart it: - - `ngcp-service rtcengine restart` - -1. If still not working, try restarting the proxy - - `ngcp-service proxy restart` - -For the call feature to properly work, make sure you're logged out of ngcp-panel before logging in to CSC. - -### PBX Customer - -You need a pbx subscriber to be able to access the pbx config specific modules in ngcp-csc-ui as a user. To create a pbx subscriber we first need to enable pbx in vagrant. - -1. SSH into the vagrant box and become root - - ` - ./v s pro1 - sudo -s - ` - -1. Edit config.yml - - `vim /etc/ngcp-config/config.yml` - -1. Enable csc by finding the "pbx:" section of the file, and setting "enable:" to yes - - `enable: yes` - -1. Enable write access for subscriberadmins by adding "write" to subscriber privileges as in the following example - - ``` - www_admin - privileges: - subscriberadmin: - subscribers: - - write - ``` - -1. Run ngcpcfg apply - - `ngcpcfg apply 'Enable pbx'` - -1. Log in to ngcp-panel with administrator credentials -1. Go to Settings > Customers -1. Look for customer row with "Cloud PBX Account" written in "Product" column, and click "Details" for that row -1. Go to Subscribers, and click "Create Subscribers". It's good to note that the first subscriber we create becomes the "pilot" for this customer -1. In the "Domains" section, select the row with your local vagrant ip address shown in the "Domain" column -1. For E.164 Number input a dummy number, for example 43 12 34567 -1. Input dummy "Display Name", "Email", "Web Username/Password", and "Sip Username/Password", and make sure to select "Administrative", then press "Save" -1. Create another subscriber. This new subscriber (and all subsequent ones) will then becomes a normal pbx subscriber -1. For normal subscribers input an extension in addition to the other fields mentioned above (including "Administrative"), then press "Save" - -Now you can log in to csc with one of the normal subscriber you just created. URL for login is the same as for accessing ngcp-panel admin, except with csc suffix and no port specified: - -`https:///csc` - -### Send Fax - -You need to first enable faxserver and activate it for the subscriber, to be able to send a fax via the "action button menu". - -1. By default, vagrant-ngcp has faxserver enabled by default in the config, so currently we do not need to make any changes here. Otherwise, it would be enabled via /etc/ngcp-config/config.yml by setting "faxserver: enable:" to "yes" and applying the changes with ngcpcfg apply 'enable faxserver'"" -1. We need to set up the MTA (Mail Transfer Agent) exim4 so we can send the fax via mail. SSH in to the vagrant system and then execute ``sudoedit /etc/ngcp-config/config.yml` with the following configuration: -`email: - domain: '' - hostname: '' - smarthost: - hostname: 'mail.sipwise.com' - password: '' - reverse_hostnames: [] - username: '' -` -1. Apply the exim configuration changes via `sudo ngcpcfg apply 'adjust exim4 / MTA configuration'` -1. Log in to ngcp-panel with administrator credentials -1. Go to "Settings > Subscribers", find subscriber you want to use as caller, and click "Details" -1. Under "Master Data" click edit, and enter subscribers number also in the E164 field -1. Then go to "Preferences", and set "Fax2Mail and Sendfax" to active -1. Repeat the two steps above, this time for the callee -1. For the callee, you also need to add your internal sipwise email address as "Destination" under "Fax2Mail and Sendfax", and also under "Call Forwards" configure a "Call Forward Unconditional" to "Fax2Mail" -1. Additionally, the visibility of the fax option in "action button menu" is reliant on store state "sendFax: true" in src/store/user.js. This means it can be toggled off in the code as well if needed - -### How to add new npm package - -1. Remove the package if you've already installed it - - `npm remove <--save-dev || --save>` - -1. Ensure that you have a clean node_modules folder - ` - rm -R node_modules/ - npm install - ` - -1. Remove obsolete shrinkwrap file - - `rm npm-shrinkwrap.json` - -1. Install new package(s) - - `npm install packageA packageB --save-dev` - -1. Generate new shrinkwrap file including all dependencies - - `npm shrinkwrap --dev` - - You should see the following result in console: - - `wrote npm-shrinkwrap.json` - -1. Add new shrinkwrap file to git - - `git add .` - -## Contributing - -See our [Contributing Guide](./CONTRIBUTING.md) file) for information on how to contribute. + `src/components/CscMainMenuTop.vue` + +```javascript +{ + to: '/user/new-feature', + icon: 'fancy_icon', + label: this.$t('navigation.newFeature.title'), + sublabel: this.$t('navigation.newFeature.subTitle'), + visible: true +} +``` + +### NGCP API + +All API functions are located in `src/api`. The file `src/api/common.js` +exports basic convenient functions to perform API requests. + +Check [API Documentation](https://dev-web-trunk.mgm.sipwise.com:1443/api) for further details. + +### Authentication + +The standard authentication method to access the API from the browser is the [JSON Web Token (JWT)](https://jwt.io) which is specified in [RFC7519](https://tools.ietf.org/html/rfc7519) + +After the login request, the JWT is stored in the LocalStorage and is added automatically on each API request. + +#### Fetch a list of items + +```javascript +const list = await getList({ + resource: 'subscribers' +}) +list.items.forEach(subscriber => { + console.log(subscriber.webusername) +}) +``` + +#### Fetch a paginated list of items + +```javascript +const list = await getList({ + resource: 'subscribers', + page: 1, + rows: 25 +}) +console.log(list.lastPage) +``` + +#### Fetch a single item +```javascript +const subscriber = await get({ + resource: 'subscribers', + resourceId: 21 +}) +console.log(subscriber.webusername) +``` + +#### Create a new item + +If you use `post`, you create the +item and get back the sanitised data. +The method `postMinimal` does exactly +the same, except that the body is empty. + +```javascript +const subscriber = await post({ + resource: 'subscribers', + body: { + webusername: 'alice', + ... + } +}) +console.log(subscriber.webusername) +``` +```javascript +await postMinimal({ + resource: 'subscribers', + body: { + webusername: 'alice', + ... + } +}) +``` + +#### Update an existing item + +This method helps to update an entire item at once. + +```javascript +const subscriber = await put({ + resource: 'subscribers', + resourceId: 21, + body: { + webusername: 'bob', + ... + } +}) +console.log(subscriber.webusername) +``` +```javascript +await putMinimal({ + resource: 'subscribers', + resourceId: 21, + body: { + webusername: 'bob', + ... + } +}) +``` + +#### Update a specific field on an existing item + +This is the preferred method to update single fields on an item. + +```javascript +await patchReplace({ + resource: 'subscribers', + resourceId: 21, + fieldPath: 'webusername', + value: 'carol' +}) +``` +```javascript +const subscriber = await patchReplaceFull({ + resource: 'subscribers', + resourceId: 21, + fieldPath: 'webusername', + value: 'dave' +}) +console.log(subscriber.webusername) diff --git a/src/api/common.js b/src/api/common.js index 40e80795..eb4c8ca7 100644 --- a/src/api/common.js +++ b/src/api/common.js @@ -9,13 +9,35 @@ export const LIST_DEFAULT_PAGE = 1 export const LIST_DEFAULT_ROWS = 25 export const LIST_ALL_ROWS = 1000 +export const ContentType = { + json: 'application/json', + jsonPatch: 'application/json-patch+json' +} + +export const Prefer = { + minimal: 'return=minimal', + representation: 'return=representation' +} + const PATCH_HEADERS = { - 'Content-Type': 'application/json-patch+json', - Prefer: 'return=minimal' + 'Content-Type': ContentType.jsonPatch, + Prefer: Prefer.minimal } const GET_HEADERS = { - Accept: 'application/json' + Accept: ContentType.json +} + +const POST_HEADERS = { + Accept: ContentType.json, + 'Content-Type': ContentType.json, + Prefer: Prefer.representation +} + +const PUT_HEADERS = { + Accept: ContentType.json, + 'Content-Type': ContentType.json, + Prefer: 'return=representation' } export class ApiResponseError extends Error { @@ -82,6 +104,16 @@ export async function getList (options) { } } +function handleResponseError (err) { + const code = _.get(err, 'body.code', null) + const message = _.get(err, 'body.message', null) + if (code !== null && message !== null) { + throw new ApiResponseError(err.body.code, err.body.message) + } else { + throw err + } +} + export async function get (options) { options = options || {} options = _.merge({ @@ -108,13 +140,7 @@ export async function get (options) { } return body } catch (err) { - const code = _.get(err, 'body.code', null) - const message = _.get(err, 'body.message', null) - if (code !== null && message !== null) { - throw new ApiResponseError(err.body.code, err.body.message) - } else { - throw err - } + handleResponseError(err) } } @@ -139,13 +165,7 @@ export async function patch (operation, options) { headers: options.headers }) } catch (err) { - const code = _.get(err, 'body.code', null) - const message = _.get(err, 'body.message', null) - if (code !== null && message !== null) { - throw new ApiResponseError(err.body.code, err.body.message) - } else { - throw err - } + handleResponseError(err) } } @@ -184,6 +204,72 @@ export function patchRemoveFull (options) { return patchFull('remove', options) } +export async function post (options) { + options = options || {} + options = _.merge({ + headers: POST_HEADERS + }, options) + let path = options.path + if (options.resource !== undefined) { + path = 'api/' + options.resource + '/' + } + try { + const res = await Vue.http.post(path, options.body, { + headers: options.headers + }) + if (options.headers.Prefer === Prefer.representation) { + return normalizeEntity(getJsonBody(res.body)) + } else { + return null + } + } catch (err) { + handleResponseError(err) + } +} + +export async function postMinimal (options) { + options = options || {} + options = _.merge(options, { + headers: { + Prefer: 'return=representation' + } + }) + await post(options) +} + +export async function put (options) { + options = options || {} + options = _.merge({ + headers: PUT_HEADERS + }, options) + let path = options.path + if (options.resource !== undefined && options.resourceId !== undefined) { + path = 'api/' + options.resource + '/' + options.resourceId + } + try { + const res = await Vue.http.put(path, options.body, { + headers: options.headers + }) + if (options.headers.Prefer === Prefer.representation) { + return normalizeEntity(getJsonBody(res.body)) + } else { + return null + } + } catch (err) { + handleResponseError(err) + } +} + +export async function putMinimal (options) { + options = options || {} + options = _.merge(options, { + headers: { + Prefer: 'return=representation' + } + }) + await put(options) +} + export async function del (options) { options = options || {} options = _.merge({ @@ -200,13 +286,7 @@ export async function del (options) { try { await Vue.http.delete(path, requestOptions) } catch (err) { - const code = _.get(err, 'body.code', null) - const message = _.get(err, 'body.message', null) - if (code !== null && message !== null) { - throw new ApiResponseError(err.body.code, err.body.message) - } else { - throw err - } + handleResponseError(err) } }