Initial working new notify - needs sprucing up but working!
This commit is contained in:
52
ayanova/package-lock.json
generated
52
ayanova/package-lock.json
generated
@@ -1068,9 +1068,9 @@
|
||||
}
|
||||
},
|
||||
"@babel/polyfill": {
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.6.0.tgz",
|
||||
"integrity": "sha512-q5BZJI0n/B10VaQQvln1IlDK3BTBJFbADx7tv+oXDPIDZuTo37H5Adb9jhlXm/fEN4Y7/64qD9mnrJJG7rmaTw==",
|
||||
"version": "7.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/polyfill/-/polyfill-7.7.0.tgz",
|
||||
"integrity": "sha512-/TS23MVvo34dFmf8mwCisCbWGrfhbiWZSwBo6HkADTBhUa2Q/jWltyY/tpofz/b6/RIhqaqQcquptCirqIhOaQ==",
|
||||
"requires": {
|
||||
"core-js": "^2.6.5",
|
||||
"regenerator-runtime": "^0.13.2"
|
||||
@@ -6925,9 +6925,9 @@
|
||||
}
|
||||
},
|
||||
"core-js": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.3.3.tgz",
|
||||
"integrity": "sha512-0xmD4vUJRY8nfLyV9zcpC17FtSie5STXzw+HyYw2t8IIvmDnbq7RJUULECCo+NstpJtwK9kx8S+898iyqgeUow=="
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.3.6.tgz",
|
||||
"integrity": "sha512-u4oM8SHwmDuh5mWZdDg9UwNVq5s1uqq6ZDLLIs07VY+VJU91i3h4f3K/pgFvtUQPGdeStrZ+odKyfyt4EnKHfA=="
|
||||
},
|
||||
"core-js-compat": {
|
||||
"version": "3.3.3",
|
||||
@@ -7535,9 +7535,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"dayjs": {
|
||||
"version": "1.8.16",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.16.tgz",
|
||||
"integrity": "sha512-XPmqzWz/EJiaRHjBqSJ2s6hE/BUoCIHKgdS2QPtTQtKcS9E4/Qn0WomoH1lXanWCzri+g7zPcuNV4aTZ8PMORQ=="
|
||||
"version": "1.8.17",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.8.17.tgz",
|
||||
"integrity": "sha512-47VY/htqYqr9GHd7HW/h56PpQzRBSJcxIQFwqL3P20bMF/3az5c3PWdVY3LmPXFl6cQCYHL7c79b9ov+2bOBbw=="
|
||||
},
|
||||
"de-indent": {
|
||||
"version": "1.0.2",
|
||||
@@ -16424,9 +16424,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"shvl": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/shvl/-/shvl-1.3.1.tgz",
|
||||
"integrity": "sha512-+rRPP46hloYUAEImJcqprUgXu+05Ikqr4h4V+w5i2zJy37nAqtkQKufs3+3S2fDq6JNRrHMIQhB/Vaex+jgAAw=="
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shvl/-/shvl-2.0.0.tgz",
|
||||
"integrity": "sha512-WbpzSvI5XgVGJ3A4ySGe8hBxj0JgJktfnoLhhJmvITDdK21WPVWwgG8GPlYEh4xqdti3Ff7PJ5G0QrRAjNS0Ig=="
|
||||
},
|
||||
"sigmund": {
|
||||
"version": "1.0.1",
|
||||
@@ -18398,14 +18398,14 @@
|
||||
"dev": true
|
||||
},
|
||||
"vuetify": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.1.6.tgz",
|
||||
"integrity": "sha512-uK5jNTbRQtnPRMDsBeXOSVgo0nKDNq7XDi987XVhK6Vb5dl6Y5kSFBFDHPpdzSUraeUtyDHrJWxq29NTPIbdFw=="
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-2.1.9.tgz",
|
||||
"integrity": "sha512-52CgEyPoGYHba5yocYKBB/LXcikoWzj9jCDTH8LlzH/hvjzkgsuEtFwUustGHyV9GstRaNZOrk4nuUWbPZc3kQ=="
|
||||
},
|
||||
"vuetify-dialog": {
|
||||
"version": "1.0.0-alpha.5",
|
||||
"resolved": "https://registry.npmjs.org/vuetify-dialog/-/vuetify-dialog-1.0.0-alpha.5.tgz",
|
||||
"integrity": "sha512-Rh1OaOvIlBKerYkgPF77WzWQ5m18RKc/4kvZvFnSBVGTrokpY/j+pd/dvaiqLQJZjAzgXqQrC/YLIMKOtHNthg=="
|
||||
"version": "2.0.0-rc.0",
|
||||
"resolved": "https://registry.npmjs.org/vuetify-dialog/-/vuetify-dialog-2.0.0-rc.0.tgz",
|
||||
"integrity": "sha512-/9SJ4DG+N3so38NCzPTBlKs6d2P0J4knSKxXlqxrI6hrLHuhV4VeEUELDkiipzxQGxd9Tro3Hl6SGKCs0VddYQ=="
|
||||
},
|
||||
"vuetify-loader": {
|
||||
"version": "1.3.0",
|
||||
@@ -18456,18 +18456,18 @@
|
||||
"integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg=="
|
||||
},
|
||||
"vuex-persistedstate": {
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/vuex-persistedstate/-/vuex-persistedstate-2.5.4.tgz",
|
||||
"integrity": "sha512-XYJhKIwO+ZVlTaXyxKxnplrJ88Fnvk5aDw753bxzRw5/yMKLQ6lq9CDCBex2fwZaQcLibhtgJOxGCHjy9GLSlQ==",
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/vuex-persistedstate/-/vuex-persistedstate-2.7.0.tgz",
|
||||
"integrity": "sha512-mpko65DUMBY4mF4sSGsgrqjE7fwO373LFZeuNrC55glRuBBAK4dkgzjr4j4Bij7WtMoKuo2t2w0NGenjauISaQ==",
|
||||
"requires": {
|
||||
"deepmerge": "^2.1.0",
|
||||
"shvl": "^1.3.0"
|
||||
"deepmerge": "^4.2.2",
|
||||
"shvl": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"deepmerge": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz",
|
||||
"integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA=="
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
|
||||
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,19 +11,19 @@
|
||||
"myLint": "npm run lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/polyfill": "^7.6.0",
|
||||
"core-js": "^3.3.2",
|
||||
"dayjs": "^1.8.16",
|
||||
"@babel/polyfill": "^7.7.0",
|
||||
"core-js": "^3.3.6",
|
||||
"dayjs": "^1.8.17",
|
||||
"jwt-decode": "^2.2.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"register-service-worker": "^1.6.2",
|
||||
"typeface-roboto": "0.0.54",
|
||||
"vue": "^2.6.10",
|
||||
"vue-router": "^3.1.3",
|
||||
"vuetify": "^2.1.6",
|
||||
"vuetify-dialog": "^1.0.0-alpha.5",
|
||||
"vuetify": "^2.1.9",
|
||||
"vuetify-dialog": "^2.0.0-rc.0",
|
||||
"vuex": "^3.0.1",
|
||||
"vuex-persistedstate": "^2.5.4"
|
||||
"vuex-persistedstate": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cypress/webpack-preprocessor": "^3.0.0",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<gznotify ref="gznotify"></gznotify>
|
||||
<!-- <gzconfirm ref="gzconfirm"></gzconfirm> -->
|
||||
<!-- <gztest ref="gztest"></gztest> -->
|
||||
<v-navigation-drawer v-if="isAuthenticated" v-model="drawer" app>
|
||||
<v-list dense>
|
||||
<v-list-item
|
||||
@@ -114,8 +117,17 @@
|
||||
<script>
|
||||
/* xeslint-disable */
|
||||
import aboutInfo from "./api/aboutinfo";
|
||||
//import gzconfirm from "./components/gzconfirm";
|
||||
import gznotify from "./components/gznotify";
|
||||
//import gztest from "./components/gztest";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
//gztest
|
||||
//gzconfirm
|
||||
// ,
|
||||
gznotify
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
drawer: null,
|
||||
@@ -143,6 +155,9 @@ export default {
|
||||
window.$gz.eventBus.$off();
|
||||
},
|
||||
mounted() {
|
||||
//this.$root.$gzconfirm = this.$refs.gzconfirm.open;
|
||||
this.$root.$gznotify = this.$refs.gznotify.open;
|
||||
|
||||
//redirect to login if not authenticated
|
||||
if (!this.$store.state.authenticated) {
|
||||
this.$router.push({ name: "login" });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
/* xeslint-disable */
|
||||
|
||||
var devModeShowErrors = false;
|
||||
|
||||
@@ -7,10 +7,10 @@ var devModeShowErrors = false;
|
||||
// Localize, Log and optionally display errors
|
||||
// return localized message in case caller needs it
|
||||
function dealWithError(msg, vm) {
|
||||
debugger;
|
||||
//debugger;
|
||||
msg = window.$gz.locale.translateString(msg);
|
||||
//In some cases the error may not be localizable, if this is not a debug run then it should show without the ?? that localizing puts in keys not found
|
||||
//so it's not as wierd looking to the user
|
||||
//so it's not as weird looking to the user
|
||||
if (!devModeShowErrors && msg.includes("??")) {
|
||||
msg = msg.replace("??", "");
|
||||
}
|
||||
@@ -20,8 +20,7 @@ function dealWithError(msg, vm) {
|
||||
var errMsg =
|
||||
"DEV ERROR errorHandler::devShowUnknownError - unexpected error: \r\n" +
|
||||
msg;
|
||||
//eslint-disable-next-line
|
||||
console.log(errMsg);
|
||||
|
||||
window.$gz.eventBus.$emit("notify-error", errMsg);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,31 @@ export default {
|
||||
wireUpEventHandlers(vm) {
|
||||
//Notifications: pops up and slowly disappears
|
||||
|
||||
/*
|
||||
This (and in form error boxes) all needs to be replaced with the stock v-alert, v-dialog and v-snackbar
|
||||
The v-alert component is used to convey important information to the user through the use contextual types icons and color. These default types come in in 4 variations: success, info, warning, and error. Default icons are assigned which help represent different actions each type portrays
|
||||
The v-snackbar component is used to display a quick message to a user. Snackbars support positioning, removal delay, and callbacks.
|
||||
The v-dialog component inform users about a specific task and may contain critical information, require decisions, or involve multiple tasks. Use dialogs sparingly because they are interruptive.
|
||||
|
||||
TODO:
|
||||
- NOTIFICATION SNACKBAR - used for temp notifications, create as a component
|
||||
- put it in the App.vue just above the router view
|
||||
- Make it respond to event bus messages and popup
|
||||
- Should be able to show more than one sequentially
|
||||
- Always auto-times out? (or...)
|
||||
|
||||
- DIALOG - used for are you sure prompts etc
|
||||
- Modal
|
||||
- callable from anywhere (sits in App.vue?)Like this: https://gist.github.com/eolant/ba0f8a5c9135d1a146e1db575276177d
|
||||
- can return data via callback (I think)
|
||||
- Has suitable types of icons
|
||||
|
||||
- ERRORBOX: Create a custom component for the top of form error box, it uses a v-alert and is consistently the same so make one re-usable component for that and us in edit forms as now but replacing the v-alert block
|
||||
- This actually is not a current requirement but as soon as there is more than one edit form it will be so...
|
||||
|
||||
|
||||
*/
|
||||
|
||||
///////////
|
||||
//ERROR
|
||||
window.$gz.eventBus.$on("notify-error", function handleNotifyWarn(msg) {
|
||||
@@ -38,11 +63,32 @@ export default {
|
||||
///////////
|
||||
//INFO
|
||||
window.$gz.eventBus.$on("notify-info", function handleNotifyWarn(msg) {
|
||||
vm.$dialog.notify.info(msg, {
|
||||
position: "bottom-right",
|
||||
icon: "fa-info-circle",
|
||||
timeout: 3000
|
||||
});
|
||||
// // eslint-disable-next-line no-debugger
|
||||
// debugger;
|
||||
// eslint-disable-next-line no-console
|
||||
//console.log(msg);
|
||||
vm.$root.$gznotify(msg, { color: "info" });
|
||||
// if (vm.$root.$gznoitfy(msg, "Are you sure?", { color: "red" })) {
|
||||
// alert("YES");
|
||||
// } else {
|
||||
// alert("CANCEL");
|
||||
// }
|
||||
// vm.$dialog.notify.info(msg, {
|
||||
// position: "bottom-right",
|
||||
// icon: "fa-info-circle",
|
||||
// timeout: 3000,
|
||||
// actions: [
|
||||
// {
|
||||
// text: window.$gz.locale.get("Cancel"),
|
||||
// key: false
|
||||
// },
|
||||
// {
|
||||
// text: window.$gz.locale.get("Delete"),
|
||||
// color: "red",
|
||||
// key: true
|
||||
// }
|
||||
// ]
|
||||
// });
|
||||
});
|
||||
|
||||
///////////
|
||||
|
||||
85
ayanova/src/components/gzconfirm.vue
Normal file
85
ayanova/src/components/gzconfirm.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
:max-width="options.width"
|
||||
:style="{ zIndex: options.zIndex }"
|
||||
@keydown.esc="cancel"
|
||||
>
|
||||
<v-card>
|
||||
<v-toolbar dark :color="options.color" dense flat>
|
||||
<v-toolbar-title class="white--text">{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text v-show="!!message" class="pa-4">{{ message }}</v-card-text>
|
||||
<v-card-actions class="pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary darken-1" text @click.native="agree">Yes</v-btn>
|
||||
<v-btn color="grey" text @click.native="cancel">Cancel</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Vuetify Confirm Dialog component
|
||||
*
|
||||
* Insert component where you want to use it:
|
||||
* <confirm ref="confirm"></confirm>
|
||||
*
|
||||
* Call it:
|
||||
* this.$refs.confirm.open('Delete', 'Are you sure?', { color: 'red' }).then((confirm) => {})
|
||||
* Or use await:
|
||||
* if (await this.$refs.confirm.open('Delete', 'Are you sure?', { color: 'red' })) {
|
||||
* // yes
|
||||
* }
|
||||
* else {
|
||||
* // cancel
|
||||
* }
|
||||
*
|
||||
* Alternatively you can place it in main App component and access it globally via this.$root.$confirm
|
||||
* <template>
|
||||
* <v-app>
|
||||
* ...
|
||||
* <confirm ref="confirm"></confirm>
|
||||
* </v-app>
|
||||
* </template>
|
||||
*
|
||||
* mounted() {
|
||||
* this.$root.$confirm = this.$refs.confirm.open
|
||||
* }
|
||||
*/
|
||||
export default {
|
||||
data: () => ({
|
||||
dialog: false,
|
||||
resolve: null,
|
||||
reject: null,
|
||||
message: null,
|
||||
title: null,
|
||||
options: {
|
||||
color: "primary",
|
||||
width: 290,
|
||||
zIndex: 200
|
||||
}
|
||||
}),
|
||||
methods: {
|
||||
open(title, message, options) {
|
||||
this.dialog = true;
|
||||
this.title = title;
|
||||
this.message = message;
|
||||
this.options = Object.assign(this.options, options);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.resolve = resolve;
|
||||
this.reject = reject;
|
||||
});
|
||||
},
|
||||
agree() {
|
||||
this.resolve(true);
|
||||
this.dialog = false;
|
||||
},
|
||||
cancel() {
|
||||
this.resolve(false);
|
||||
this.dialog = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
51
ayanova/src/components/gznotify.vue
Normal file
51
ayanova/src/components/gznotify.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<!-- <v-dialog
|
||||
v-model="dialog"
|
||||
:max-width="options.width"
|
||||
:style="{ zIndex: options.zIndex }"
|
||||
@keydown.esc="cancel"
|
||||
>
|
||||
<v-card>
|
||||
<v-toolbar dark :color="options.color" dense flat>
|
||||
<v-toolbar-title class="white--text">{{ title }}</v-toolbar-title>
|
||||
</v-toolbar>
|
||||
<v-card-text v-show="!!message" class="pa-4">{{ message }}</v-card-text>
|
||||
<v-card-actions class="pt-0">
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary darken-1" text @click.native="agree">Yes</v-btn>
|
||||
<v-btn color="grey" text @click.native="cancel">Cancel</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog> -->
|
||||
<v-snackbar v-model="snackbar">
|
||||
{{ message }}
|
||||
<v-btn
|
||||
:color="options.color"
|
||||
:timeout="options.timeout"
|
||||
text
|
||||
@click="snackbar = false"
|
||||
>
|
||||
Close
|
||||
</v-btn>
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
snackbar: false,
|
||||
message: null,
|
||||
options: {
|
||||
color: "primary",
|
||||
timeout: 3000
|
||||
}
|
||||
}),
|
||||
methods: {
|
||||
open(message, options) {
|
||||
this.snackbar = true;
|
||||
this.message = message;
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
23
ayanova/src/components/gztest.vue
Normal file
23
ayanova/src/components/gztest.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div>{{ message }}</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data: () => ({
|
||||
snackbar: false,
|
||||
message: null,
|
||||
options: {
|
||||
color: "primary",
|
||||
timeout: 3000
|
||||
}
|
||||
}),
|
||||
methods: {
|
||||
open(message, options) {
|
||||
this.snackbar = true;
|
||||
this.message = message;
|
||||
this.options = Object.assign(this.options, options);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -90,7 +90,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* eslint-disable */
|
||||
/* Xeslint-disable */
|
||||
const FORM_KEY = "inventorywidgetlist";
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable */
|
||||
/* Xeslint-disable */
|
||||
import "@babel/polyfill";
|
||||
import "@fortawesome/fontawesome-free/css/all.css";
|
||||
import "typeface-roboto/index.css";
|
||||
@@ -167,7 +167,6 @@ Vue.component("gz-date-time-picker", dateTimeControl);
|
||||
Vue.component("gz-tag-picker", tagPicker);
|
||||
Vue.component("gz-custom-fields", customFieldsControl);
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
//3rd party ui components
|
||||
//
|
||||
|
||||
@@ -12,8 +12,17 @@
|
||||
<v-col cols="12" class="d-flex d-md-none">
|
||||
<v-img :src="require('../assets/logo.svg')" contain height="64"></v-img>
|
||||
</v-col>
|
||||
<!-- <v-col cols="12" class="purple">
|
||||
<v-skeleton-loader ref="skeleton" type="image"></v-skeleton-loader>
|
||||
<!-- <v-col cols="12">
|
||||
<v-alert border="left" color="deep-purple accent-4" dark dismissible>
|
||||
Aenean imperdiet. Quisque id odio. Cras dapibus. Pellentesque ut
|
||||
neque. Cras dapibus. Vivamus consectetuer hendrerit lacus. Sed mollis,
|
||||
eros et ultrices tempus, mauris ipsum aliquam libero, non adipiscing
|
||||
dolor urna a orci. Sed mollis, eros et ultrices tempus, mauris ipsum
|
||||
aliquam libero, non adipiscing dolor urna a orci. Curabitur blandit
|
||||
mollis lacus. Curabitur ligula sapien, tincidunt non, euismod vitae,
|
||||
posuere imperdiet, leo.
|
||||
</v-alert>
|
||||
<v-skeleton-loader ref="skeleton" type="image"></v-skeleton-loader>
|
||||
</v-col> -->
|
||||
<v-col cols="12" offset="3">
|
||||
<form>
|
||||
@@ -91,8 +100,7 @@ export default {
|
||||
if (this.input.username != "" && this.input.password != "") {
|
||||
this.errorBadCreds = false;
|
||||
var vm = this;
|
||||
// eslint-disable-next-line no-debugger
|
||||
debugger;
|
||||
|
||||
auth
|
||||
.authenticate(this.input.username, this.input.password)
|
||||
.then(() => {
|
||||
|
||||
3
ayanova/vue.config.js
Normal file
3
ayanova/vue.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
transpileDependencies: ["vuetify"]
|
||||
};
|
||||
Reference in New Issue
Block a user