Initial version of smartbooking generated by generator-jhipster@9.0.0-beta.0
This commit is contained in:
58
src/main/webapp/404.html
Normal file
58
src/main/webapp/404.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Page Not Found</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<style>
|
||||
* {
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
color: #888;
|
||||
display: table;
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
margin: 2em auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #555;
|
||||
font-size: 2em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 auto;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 280px) {
|
||||
body,
|
||||
p {
|
||||
width: 95%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin: 0 0 0.3em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>Sorry, but the page you were trying to view does not exist.</p>
|
||||
</body>
|
||||
</html>
|
||||
<!-- IE needs 512+ bytes: http://blogs.msdn.com/b/ieinternals/archive/2010/08/19/http-error-pages-in-internet-explorer.aspx -->
|
||||
13
src/main/webapp/WEB-INF/web.xml
Normal file
13
src/main/webapp/WEB-INF/web.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<web-app
|
||||
xmlns="http://java.sun.com/xml/ns/javaee"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
|
||||
version="3.0">
|
||||
|
||||
<mime-mapping>
|
||||
<extension>html</extension>
|
||||
<mime-type>text/html;charset=utf-8</mime-type>
|
||||
</mime-mapping>
|
||||
|
||||
</web-app>
|
||||
110
src/main/webapp/app/account/account.service.spec.ts
Normal file
110
src/main/webapp/app/account/account.service.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { type AccountStore, useStore } from '@/store';
|
||||
|
||||
import AccountService from './account.service';
|
||||
|
||||
const resetStore = (store: AccountStore) => {
|
||||
store.$reset();
|
||||
};
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
createTestingPinia({ stubActions: false });
|
||||
const store = useStore();
|
||||
|
||||
describe('Account Service test suite', () => {
|
||||
let accountService: AccountService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
axiosStub.get.reset();
|
||||
resetStore(store);
|
||||
});
|
||||
|
||||
it('should init service and do not retrieve account', async () => {
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.get
|
||||
.withArgs('management/info')
|
||||
.resolves({ status: 200, data: { 'display-ribbon-on-profiles': 'dev', activeProfiles: ['dev', 'test'] } });
|
||||
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
expect(store.logon).toBe(null);
|
||||
expect(accountService.authenticated).toBe(false);
|
||||
expect(store.account).toBe(null);
|
||||
expect(axiosStub.get.calledWith('management/info')).toBeTruthy();
|
||||
expect(store.activeProfiles[0]).toBe('dev');
|
||||
expect(store.activeProfiles[1]).toBe('test');
|
||||
expect(store.ribbonOnProfiles).toBe('dev');
|
||||
});
|
||||
|
||||
it('should init service and retrieve profiles if already logged in before but no account found', async () => {
|
||||
axiosStub.get.resolves({});
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
expect(store.logon).toBe(null);
|
||||
expect(accountService.authenticated).toBe(false);
|
||||
expect(store.account).toBe(null);
|
||||
expect(axiosStub.get.calledWith('management/info')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should init service and retrieve profiles if already logged in before but exception occurred and should be logged out', async () => {
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.get.withArgs('api/account').rejects();
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
expect(accountService.authenticated).toBe(false);
|
||||
expect(store.account).toBe(null);
|
||||
expect(axiosStub.get.calledWith('management/info')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should init service and check for authority after retrieving account but getAccount failed', async () => {
|
||||
axiosStub.get.rejects();
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => {
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should init service and check for authority after retrieving account', async () => {
|
||||
axiosStub.get.resolves({ status: 200, data: { authorities: ['USER'], langKey: 'en', login: 'ADMIN' } });
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => {
|
||||
expect(value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should init service as not authenticated and not return any authorities admin and not retrieve account', async () => {
|
||||
axiosStub.get.rejects();
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
return accountService.hasAnyAuthorityAndCheckAuth('ADMIN').then((value: boolean) => {
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should init service as not authenticated and return authority user', async () => {
|
||||
axiosStub.get.rejects();
|
||||
accountService = new AccountService(store);
|
||||
await accountService.update();
|
||||
|
||||
return accountService.hasAnyAuthorityAndCheckAuth('USER').then((value: boolean) => {
|
||||
expect(value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
src/main/webapp/app/account/account.service.ts
Normal file
85
src/main/webapp/app/account/account.service.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { type AccountStore } from '@/store';
|
||||
|
||||
export default class AccountService {
|
||||
constructor(private store: AccountStore) {}
|
||||
|
||||
async update(): Promise<void> {
|
||||
if (!this.store.profilesLoaded) {
|
||||
await this.retrieveProfiles();
|
||||
this.store.setProfilesLoaded();
|
||||
}
|
||||
await this.loadAccount();
|
||||
}
|
||||
|
||||
async retrieveProfiles(): Promise<boolean> {
|
||||
try {
|
||||
const res = await axios.get<any>('management/info');
|
||||
if (res.data?.activeProfiles) {
|
||||
this.store.setRibbonOnProfiles(res.data['display-ribbon-on-profiles']);
|
||||
this.store.setActiveProfiles(res.data.activeProfiles);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveAccount(): Promise<boolean> {
|
||||
try {
|
||||
const response = await axios.get<any>('api/account');
|
||||
if (response.status === 200 && response.data?.login) {
|
||||
const account = response.data;
|
||||
this.store.setAuthentication(account);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
this.store.logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
async loadAccount() {
|
||||
if (this.store.logon) {
|
||||
return this.store.logon;
|
||||
}
|
||||
if (this.authenticated && this.userAuthorities) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promise = this.retrieveAccount();
|
||||
this.store.authenticate(promise);
|
||||
promise.then(() => this.store.authenticate(null));
|
||||
await promise;
|
||||
}
|
||||
|
||||
async hasAnyAuthorityAndCheckAuth(authorities: any): Promise<boolean> {
|
||||
if (typeof authorities === 'string') {
|
||||
authorities = [authorities];
|
||||
}
|
||||
|
||||
return this.checkAuthorities(authorities);
|
||||
}
|
||||
|
||||
get authenticated(): boolean {
|
||||
return this.store.authenticated;
|
||||
}
|
||||
|
||||
get userAuthorities(): string[] {
|
||||
return this.store.account?.authorities;
|
||||
}
|
||||
|
||||
private checkAuthorities(authorities: string[]): boolean {
|
||||
if (this.userAuthorities) {
|
||||
for (const authority of authorities) {
|
||||
if (this.userAuthorities.includes(authority)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { vitest } from 'vitest';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { type ComponentMountingOptions, shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import Activate from './activate.vue';
|
||||
|
||||
type ActivateComponentType = InstanceType<typeof Activate>;
|
||||
|
||||
const route = { query: { key: 'key' } };
|
||||
|
||||
vitest.mock('vue-router', () => ({
|
||||
useRoute: () => route,
|
||||
}));
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Activate Component', () => {
|
||||
let activate: ActivateComponentType;
|
||||
let mountOptions: ComponentMountingOptions<ActivateComponentType>;
|
||||
|
||||
beforeEach(() => {
|
||||
mountOptions = {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('should display error when activation fails', async () => {
|
||||
axiosStub.get.rejects({});
|
||||
|
||||
const wrapper = shallowMount(Activate as any, mountOptions);
|
||||
activate = wrapper.vm;
|
||||
await activate.$nextTick();
|
||||
|
||||
expect(activate.error).toBeTruthy();
|
||||
expect(activate.success).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should display success when activation succeeds', async () => {
|
||||
axiosStub.get.resolves({});
|
||||
|
||||
const wrapper = shallowMount(Activate as any, mountOptions);
|
||||
activate = wrapper.vm;
|
||||
await activate.$nextTick();
|
||||
|
||||
expect(activate.error).toBeFalsy();
|
||||
expect(activate.success).toBeTruthy();
|
||||
});
|
||||
});
|
||||
38
src/main/webapp/app/account/activate/activate.component.ts
Normal file
38
src/main/webapp/app/account/activate/activate.component.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { type Ref, defineComponent, inject, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
|
||||
import ActivateService from './activate.service';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const activateService = inject('activateService', () => new ActivateService(), true);
|
||||
const { showLogin } = useLoginModal();
|
||||
const route = useRoute();
|
||||
|
||||
const success: Ref<boolean> = ref(false);
|
||||
const error: Ref<boolean> = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
const key = Array.isArray(route.query.key) ? route.query.key[0] : route.query.key;
|
||||
try {
|
||||
await activateService.activateAccount(key);
|
||||
success.value = true;
|
||||
error.value = false;
|
||||
} catch {
|
||||
error.value = true;
|
||||
success.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activateService,
|
||||
showLogin,
|
||||
success,
|
||||
error,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
13
src/main/webapp/app/account/activate/activate.service.ts
Normal file
13
src/main/webapp/app/account/activate/activate.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import axios, { type AxiosInstance } from 'axios';
|
||||
|
||||
export default class ActivateService {
|
||||
private axios: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.axios = axios;
|
||||
}
|
||||
|
||||
activateAccount(key: string): Promise<any> {
|
||||
return this.axios.get(`api/activate?key=${key}`);
|
||||
}
|
||||
}
|
||||
17
src/main/webapp/app/account/activate/activate.vue
Normal file
17
src/main/webapp/app/account/activate/activate.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<h1>{{ t$('activate.title') }}</h1>
|
||||
<div class="alert alert-success" v-if="success">
|
||||
<span v-html="t$('activate.messages.success')"></span>
|
||||
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a
|
||||
>.
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="error" v-html="t$('activate.messages.error')"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./activate.component.ts"></script>
|
||||
@@ -0,0 +1,85 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import ChangePassword from './change-password.vue';
|
||||
|
||||
type ChangePasswordComponentType = InstanceType<typeof ChangePassword>;
|
||||
|
||||
const pinia = createTestingPinia();
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('ChangePassword Component', () => {
|
||||
let changePassword: ChangePasswordComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.post.reset();
|
||||
|
||||
const wrapper = shallowMount(ChangePassword, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
provide: {
|
||||
currentUsername: computed(() => 'username'),
|
||||
},
|
||||
},
|
||||
});
|
||||
changePassword = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should show error if passwords do not match', () => {
|
||||
// GIVEN
|
||||
changePassword.resetPassword = { newPassword: 'password1', confirmPassword: 'password2' };
|
||||
// WHEN
|
||||
changePassword.changePassword();
|
||||
// THEN
|
||||
expect(changePassword.doNotMatch).toBe('ERROR');
|
||||
expect(changePassword.error).toBeNull();
|
||||
expect(changePassword.success).toBeNull();
|
||||
});
|
||||
|
||||
it('should call Auth.changePassword when passwords match and set success to OK upon success', async () => {
|
||||
// GIVEN
|
||||
changePassword.resetPassword = { currentPassword: 'password1', newPassword: 'password1', confirmPassword: 'password1' };
|
||||
axiosStub.post.resolves({});
|
||||
|
||||
// WHEN
|
||||
changePassword.changePassword();
|
||||
await changePassword.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/account/change-password', {
|
||||
currentPassword: 'password1',
|
||||
newPassword: 'password1',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(changePassword.doNotMatch).toBeNull();
|
||||
expect(changePassword.error).toBeNull();
|
||||
expect(changePassword.success).toBe('OK');
|
||||
});
|
||||
|
||||
it('should notify of error if change password fails', async () => {
|
||||
// GIVEN
|
||||
changePassword.resetPassword = { currentPassword: 'password1', newPassword: 'password1', confirmPassword: 'password1' };
|
||||
axiosStub.post.rejects({});
|
||||
|
||||
// WHEN
|
||||
changePassword.changePassword();
|
||||
await changePassword.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(changePassword.doNotMatch).toBeNull();
|
||||
expect(changePassword.success).toBeNull();
|
||||
await changePassword.$nextTick();
|
||||
expect(changePassword.error).toBe('ERROR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { maxLength, minLength, required, sameAs } from '@vuelidate/validators';
|
||||
import axios from 'axios';
|
||||
|
||||
export default defineComponent({
|
||||
validations() {
|
||||
return {
|
||||
resetPassword: {
|
||||
currentPassword: {
|
||||
required,
|
||||
},
|
||||
newPassword: {
|
||||
required,
|
||||
minLength: minLength(4),
|
||||
maxLength: maxLength(254),
|
||||
},
|
||||
confirmPassword: {
|
||||
sameAsPassword: sameAs(this.resetPassword.newPassword),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
const username = inject<ComputedRef<string>>('currentUsername');
|
||||
const success: Ref<string> = ref(null);
|
||||
const error: Ref<string> = ref(null);
|
||||
const doNotMatch: Ref<string> = ref(null);
|
||||
const resetPassword: Ref<any> = ref({
|
||||
currentPassword: null,
|
||||
newPassword: null,
|
||||
confirmPassword: null,
|
||||
});
|
||||
|
||||
return {
|
||||
username,
|
||||
success,
|
||||
error,
|
||||
doNotMatch,
|
||||
resetPassword,
|
||||
v$: useVuelidate(),
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
changePassword(): void {
|
||||
if (this.resetPassword.newPassword !== this.resetPassword.confirmPassword) {
|
||||
this.error = null;
|
||||
this.success = null;
|
||||
this.doNotMatch = 'ERROR';
|
||||
} else {
|
||||
this.doNotMatch = null;
|
||||
axios
|
||||
.post('api/account/change-password', {
|
||||
currentPassword: this.resetPassword.currentPassword,
|
||||
newPassword: this.resetPassword.newPassword,
|
||||
})
|
||||
.then(() => {
|
||||
this.success = 'OK';
|
||||
this.error = null;
|
||||
})
|
||||
.catch(() => {
|
||||
this.success = null;
|
||||
this.error = 'ERROR';
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8 toastify-container">
|
||||
<h2 v-if="username" id="password-title">
|
||||
<span v-html="t$('password.title', { username })"></span>
|
||||
</h2>
|
||||
|
||||
<div class="alert alert-success" role="alert" v-if="success" v-html="t$('password.messages.success')"></div>
|
||||
<div class="alert alert-danger" role="alert" v-if="error" v-html="t$('password.messages.error')"></div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" v-if="doNotMatch">{{ t$('global.messages.error.dontmatch') }}</div>
|
||||
|
||||
<form name="form" id="password-form" @submit.prevent="changePassword()">
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="currentPassword">{{ t$("global.form['currentpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="currentPassword"
|
||||
name="currentPassword"
|
||||
:class="{ 'is-valid': !v$.resetPassword.currentPassword.$invalid, 'is-invalid': v$.resetPassword.currentPassword.$invalid }"
|
||||
:placeholder="t$('global.form[\'currentpassword.placeholder\']')"
|
||||
v-model="v$.resetPassword.currentPassword.$model"
|
||||
required
|
||||
data-cy="currentPassword"
|
||||
/>
|
||||
<div v-if="v$.resetPassword.currentPassword.$anyDirty && v$.resetPassword.currentPassword.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.resetPassword.currentPassword.required.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.required')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="newPassword">{{ t$("global.form['newpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
:placeholder="t$('global.form[\'newpassword.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.resetPassword.newPassword.$invalid, 'is-invalid': v$.resetPassword.newPassword.$invalid }"
|
||||
v-model="v$.resetPassword.newPassword.$model"
|
||||
minlength="4"
|
||||
maxlength="50"
|
||||
required
|
||||
data-cy="newPassword"
|
||||
/>
|
||||
<div v-if="v$.resetPassword.newPassword.$anyDirty && v$.resetPassword.newPassword.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.resetPassword.newPassword.required.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetPassword.newPassword.minLength.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetPassword.newPassword.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="confirmPassword">{{ t$("global.form['confirmpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
:class="{ 'is-valid': !v$.resetPassword.confirmPassword.$invalid, 'is-invalid': v$.resetPassword.confirmPassword.$invalid }"
|
||||
:placeholder="t$('global.form[\'confirmpassword.placeholder\']')"
|
||||
v-model="v$.resetPassword.confirmPassword.$model"
|
||||
minlength="4"
|
||||
maxlength="50"
|
||||
required
|
||||
data-cy="confirmPassword"
|
||||
/>
|
||||
<div v-if="v$.resetPassword.confirmPassword.$anyDirty && v$.resetPassword.confirmPassword.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.resetPassword.confirmPassword.sameAsPassword.$invalid">{{
|
||||
t$('global.messages.error.dontmatch')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="v$.resetPassword.$invalid" class="btn btn-primary" data-cy="submit">
|
||||
{{ t$('password.form.button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./change-password.component.ts"></script>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { vitest } from 'vitest';
|
||||
import { type RouteLocation } from 'vue-router';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { type MountingOptions, shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { useStore } from '@/store';
|
||||
import AccountService from '../account.service';
|
||||
|
||||
import LoginForm from './login-form.vue';
|
||||
|
||||
type LoginFormComponentType = InstanceType<typeof LoginForm>;
|
||||
|
||||
let route: Partial<RouteLocation>;
|
||||
const routerGoMock = vitest.fn();
|
||||
vitest.mock('vue-router', () => ({
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({ go: routerGoMock }),
|
||||
}));
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('LoginForm Component', () => {
|
||||
let loginForm: LoginFormComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
route = {};
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.post.reset();
|
||||
|
||||
const pinia = createTestingPinia();
|
||||
const store = useStore();
|
||||
|
||||
const globalOptions: MountingOptions<LoginFormComponentType>['global'] = {
|
||||
stubs: {
|
||||
'b-alert': true,
|
||||
'b-button': true,
|
||||
'b-form': true,
|
||||
'b-form-input': true,
|
||||
'b-form-group': true,
|
||||
'b-form-checkbox': true,
|
||||
'b-link': true,
|
||||
},
|
||||
plugins: [pinia],
|
||||
provide: {
|
||||
accountService: new AccountService(store),
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(LoginForm, { global: globalOptions });
|
||||
|
||||
loginForm = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should authentication be KO', async () => {
|
||||
// GIVEN
|
||||
loginForm.login = 'login';
|
||||
loginForm.password = 'pwd';
|
||||
loginForm.rememberMe = true;
|
||||
axiosStub.post.rejects();
|
||||
|
||||
// WHEN
|
||||
loginForm.doLogin();
|
||||
await loginForm.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/authentication', 'username=login&password=pwd&remember-me=true&submit=Login', {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
}),
|
||||
).toBeTruthy();
|
||||
await loginForm.$nextTick();
|
||||
expect(loginForm.authenticationError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should authentication be OK', async () => {
|
||||
// GIVEN
|
||||
loginForm.login = 'login';
|
||||
loginForm.password = 'pwd';
|
||||
loginForm.rememberMe = true;
|
||||
axiosStub.post.resolves({});
|
||||
|
||||
// WHEN
|
||||
loginForm.doLogin();
|
||||
await loginForm.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/authentication', 'username=login&password=pwd&remember-me=true&submit=Login', {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
}),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(loginForm.authenticationError).toBeFalsy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import type AccountService from '../account.service';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const authenticationError: Ref<boolean> = ref(false);
|
||||
const login: Ref<string> = ref(null);
|
||||
const password: Ref<string> = ref(null);
|
||||
const rememberMe: Ref<boolean> = ref(false);
|
||||
|
||||
const { hideLogin } = useLoginModal();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const previousState = () => router.go(-1);
|
||||
|
||||
const accountService = inject<AccountService>('accountService');
|
||||
|
||||
const doLogin = async () => {
|
||||
const data = `username=${encodeURIComponent(login.value)}&password=${encodeURIComponent(password.value)}&remember-me=${rememberMe.value}&submit=Login`;
|
||||
try {
|
||||
await axios.post('api/authentication', data, {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
authenticationError.value = false;
|
||||
hideLogin();
|
||||
await accountService.retrieveAccount();
|
||||
if (route.path === '/forbidden') {
|
||||
previousState();
|
||||
}
|
||||
} catch {
|
||||
authenticationError.value = true;
|
||||
}
|
||||
};
|
||||
return {
|
||||
authenticationError,
|
||||
login,
|
||||
password,
|
||||
rememberMe,
|
||||
accountService,
|
||||
doLogin,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
62
src/main/webapp/app/account/login-form/login-form.vue
Normal file
62
src/main/webapp/app/account/login-form/login-form.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="row-md">
|
||||
<div class="col-md-12">
|
||||
<b-alert
|
||||
data-cy="loginError"
|
||||
variant="danger"
|
||||
:model-value="authenticationError"
|
||||
v-html="t$('login.messages.error.authentication')"
|
||||
></b-alert>
|
||||
</div>
|
||||
<div class="col-md-12">
|
||||
<b-form @submit.prevent="doLogin()">
|
||||
<b-form-group :label="t$('global.form[\'username.label\']')" label-for="username">
|
||||
<b-form-input
|
||||
id="username"
|
||||
type="text"
|
||||
name="username"
|
||||
:placeholder="t$('global.form[\'username.placeholder\']')"
|
||||
v-model="login"
|
||||
data-cy="username"
|
||||
>
|
||||
</b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-group :label="t$('login.form.password')" label-for="password">
|
||||
<b-form-input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
:placeholder="t$('login.form[\'password.placeholder\']')"
|
||||
v-model="password"
|
||||
data-cy="password"
|
||||
>
|
||||
</b-form-input>
|
||||
</b-form-group>
|
||||
<b-form-checkbox id="rememberMe" name="rememberMe" v-model="rememberMe" checked>
|
||||
<span>{{ t$('login.form.rememberme') }}</span>
|
||||
</b-form-checkbox>
|
||||
<div>
|
||||
<b-button data-cy="submit" type="submit" variant="primary">{{ t$('login.form.button') }}</b-button>
|
||||
</div>
|
||||
</b-form>
|
||||
<p></p>
|
||||
<div>
|
||||
<b-alert :model-value="true" variant="warning">
|
||||
<b-link :to="'/account/reset/request'" class="alert-link" data-cy="forgetYourPasswordSelector">{{
|
||||
t$('login.password.forgot')
|
||||
}}</b-link>
|
||||
</b-alert>
|
||||
</div>
|
||||
<div>
|
||||
<b-alert :model-value="true" variant="warning">
|
||||
<span>{{ t$('global.messages.info.register.noaccount') }}</span>
|
||||
<b-link :to="'/register'" class="alert-link">{{ t$('global.messages.info.register.link') }}</b-link>
|
||||
</b-alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./login-form.component.ts"></script>
|
||||
21
src/main/webapp/app/account/login-modal.ts
Normal file
21
src/main/webapp/app/account/login-modal.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
export const useLoginModal = defineStore('login', () => {
|
||||
const loginModalOpen = ref(false);
|
||||
|
||||
function showLogin() {
|
||||
loginModalOpen.value = true;
|
||||
}
|
||||
|
||||
function hideLogin() {
|
||||
loginModalOpen.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
loginModalOpen,
|
||||
showLogin,
|
||||
hideLogin,
|
||||
};
|
||||
});
|
||||
23
src/main/webapp/app/account/login.service.spec.ts
Normal file
23
src/main/webapp/app/account/login.service.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import LoginService from './login.service';
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Login Service test suite', () => {
|
||||
let loginService: LoginService;
|
||||
|
||||
beforeEach(() => {
|
||||
loginService = new LoginService({ emit: vitest.fn() });
|
||||
});
|
||||
|
||||
it('should call global logout when asked to', () => {
|
||||
loginService.logout();
|
||||
|
||||
expect(axiosStub.post.calledWith('api/logout')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
7
src/main/webapp/app/account/login.service.ts
Normal file
7
src/main/webapp/app/account/login.service.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import axios, { type AxiosPromise } from 'axios';
|
||||
|
||||
export default class LoginService {
|
||||
logout(): AxiosPromise<any> {
|
||||
return axios.post('api/logout');
|
||||
}
|
||||
}
|
||||
135
src/main/webapp/app/account/register/register.component.spec.ts
Normal file
135
src/main/webapp/app/account/register/register.component.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from '@/constants';
|
||||
|
||||
import Register from './register.vue';
|
||||
|
||||
type RegisterComponentType = InstanceType<typeof Register>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Register Component', () => {
|
||||
let register: RegisterComponentType;
|
||||
const filledRegisterAccount = {
|
||||
email: 'jhi@pster.net',
|
||||
langKey: 'it',
|
||||
login: 'jhi',
|
||||
password: 'jhipster',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.post.reset();
|
||||
|
||||
const wrapper = shallowMount(Register, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
provide: {
|
||||
currentLanguage: computed(() => 'it'),
|
||||
},
|
||||
},
|
||||
});
|
||||
register = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should set all default values correctly', () => {
|
||||
expect(register.success).toBe(false);
|
||||
expect(register.error).toBe('');
|
||||
expect(register.errorEmailExists).toBe('');
|
||||
expect(register.errorUserExists).toBe('');
|
||||
expect(register.confirmPassword).toBe(null);
|
||||
expect(register.registerAccount.login).toBe(undefined);
|
||||
expect(register.registerAccount.password).toBe(undefined);
|
||||
expect(register.registerAccount.email).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should open login modal when asked to', () => {
|
||||
const login = useLoginModal();
|
||||
register.showLogin();
|
||||
expect(login.showLogin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('should register when password match', async () => {
|
||||
axiosStub.post.resolves();
|
||||
register.registerAccount = filledRegisterAccount;
|
||||
register.confirmPassword = filledRegisterAccount.password;
|
||||
register.register();
|
||||
await register.$nextTick();
|
||||
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/register', {
|
||||
email: 'jhi@pster.net',
|
||||
langKey: 'it',
|
||||
login: 'jhi',
|
||||
password: 'jhipster',
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(register.success).toBe(true);
|
||||
expect(register.error).toBe(null);
|
||||
expect(register.errorEmailExists).toBe(null);
|
||||
expect(register.errorUserExists).toBe(null);
|
||||
});
|
||||
|
||||
it('should register when password match but throw error when login already exist', async () => {
|
||||
const error = { response: { status: 400, data: { type: LOGIN_ALREADY_USED_TYPE } } };
|
||||
axiosStub.post.rejects(error);
|
||||
register.registerAccount = filledRegisterAccount;
|
||||
register.confirmPassword = filledRegisterAccount.password;
|
||||
register.register();
|
||||
await register.$nextTick();
|
||||
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/register', { email: 'jhi@pster.net', langKey: 'it', login: 'jhi', password: 'jhipster' }),
|
||||
).toBeTruthy();
|
||||
await register.$nextTick();
|
||||
expect(register.success).toBe(null);
|
||||
expect(register.error).toBe(null);
|
||||
expect(register.errorEmailExists).toBe(null);
|
||||
expect(register.errorUserExists).toBe('ERROR');
|
||||
});
|
||||
|
||||
it('should register when password match but throw error when email already used', async () => {
|
||||
const error = { response: { status: 400, data: { type: EMAIL_ALREADY_USED_TYPE } } };
|
||||
axiosStub.post.rejects(error);
|
||||
register.registerAccount = filledRegisterAccount;
|
||||
register.confirmPassword = filledRegisterAccount.password;
|
||||
register.register();
|
||||
await register.$nextTick();
|
||||
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/register', { email: 'jhi@pster.net', langKey: 'it', login: 'jhi', password: 'jhipster' }),
|
||||
).toBeTruthy();
|
||||
await register.$nextTick();
|
||||
expect(register.success).toBe(null);
|
||||
expect(register.error).toBe(null);
|
||||
expect(register.errorEmailExists).toBe('ERROR');
|
||||
expect(register.errorUserExists).toBe(null);
|
||||
});
|
||||
|
||||
it('should register when password match but throw error', async () => {
|
||||
const error = { response: { status: 400, data: { type: 'unknown' } } };
|
||||
axiosStub.post.rejects(error);
|
||||
register.registerAccount = filledRegisterAccount;
|
||||
register.confirmPassword = filledRegisterAccount.password;
|
||||
register.register();
|
||||
await register.$nextTick();
|
||||
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/register', { email: 'jhi@pster.net', langKey: 'it', login: 'jhi', password: 'jhipster' }),
|
||||
).toBeTruthy();
|
||||
await register.$nextTick();
|
||||
expect(register.success).toBe(null);
|
||||
expect(register.errorEmailExists).toBe(null);
|
||||
expect(register.errorUserExists).toBe(null);
|
||||
expect(register.error).toBe('ERROR');
|
||||
});
|
||||
});
|
||||
98
src/main/webapp/app/account/register/register.component.ts
Normal file
98
src/main/webapp/app/account/register/register.component.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { email, helpers, maxLength, minLength, required, sameAs } from '@vuelidate/validators';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import RegisterService from '@/account/register/register.service';
|
||||
import { EMAIL_ALREADY_USED_TYPE, LOGIN_ALREADY_USED_TYPE } from '@/constants';
|
||||
|
||||
const loginPattern = helpers.regex(/^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/);
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Register',
|
||||
validations() {
|
||||
return {
|
||||
registerAccount: {
|
||||
login: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
maxLength: maxLength(50),
|
||||
pattern: loginPattern,
|
||||
},
|
||||
email: {
|
||||
required,
|
||||
minLength: minLength(5),
|
||||
maxLength: maxLength(254),
|
||||
email,
|
||||
},
|
||||
password: {
|
||||
required,
|
||||
minLength: minLength(4),
|
||||
maxLength: maxLength(254),
|
||||
},
|
||||
},
|
||||
confirmPassword: {
|
||||
required,
|
||||
minLength: minLength(4),
|
||||
maxLength: maxLength(50),
|
||||
sameAsPassword: sameAs(this.registerAccount.password),
|
||||
},
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
const { showLogin } = useLoginModal();
|
||||
const registerService = inject('registerService', () => new RegisterService(), true);
|
||||
const currentLanguage = inject('currentLanguage', () => computed(() => navigator.language ?? 'it'), true);
|
||||
|
||||
const error: Ref<string> = ref('');
|
||||
const errorEmailExists: Ref<string> = ref('');
|
||||
const errorUserExists: Ref<string> = ref('');
|
||||
const success: Ref<boolean> = ref(false);
|
||||
|
||||
const confirmPassword: Ref<any> = ref(null);
|
||||
const registerAccount: Ref<any> = ref({
|
||||
login: undefined,
|
||||
email: undefined,
|
||||
password: undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
showLogin,
|
||||
currentLanguage,
|
||||
registerService,
|
||||
error,
|
||||
errorEmailExists,
|
||||
errorUserExists,
|
||||
success,
|
||||
confirmPassword,
|
||||
registerAccount,
|
||||
v$: useVuelidate(),
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
register(): void {
|
||||
this.error = null;
|
||||
this.errorUserExists = null;
|
||||
this.errorEmailExists = null;
|
||||
this.registerAccount.langKey = this.currentLanguage;
|
||||
this.registerService
|
||||
.processRegistration(this.registerAccount)
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
})
|
||||
.catch(error => {
|
||||
this.success = null;
|
||||
if (error.response.status === 400 && error.response.data.type === LOGIN_ALREADY_USED_TYPE) {
|
||||
this.errorUserExists = 'ERROR';
|
||||
} else if (error.response.status === 400 && error.response.data.type === EMAIL_ALREADY_USED_TYPE) {
|
||||
this.errorEmailExists = 'ERROR';
|
||||
} else {
|
||||
this.error = 'ERROR';
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
7
src/main/webapp/app/account/register/register.service.ts
Normal file
7
src/main/webapp/app/account/register/register.service.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export default class RegisterService {
|
||||
processRegistration(account: any): Promise<any> {
|
||||
return axios.post('api/register', account);
|
||||
}
|
||||
}
|
||||
152
src/main/webapp/app/account/register/register.vue
Normal file
152
src/main/webapp/app/account/register/register.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8 toastify-container">
|
||||
<h1 id="register-title" data-cy="registerTitle">{{ t$('register.title') }}</h1>
|
||||
|
||||
<div class="alert alert-success" role="alert" v-if="success" v-html="t$('register.messages.success')"></div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" v-if="error" v-html="t$('register.messages.error.fail')"></div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" v-if="errorUserExists" v-html="t$('register.messages.error.userexists')"></div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" v-if="errorEmailExists" v-html="t$('register.messages.error.emailexists')"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<form id="register-form" name="registerForm" @submit.prevent="register()" v-if="!success" no-validate>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="username">{{ t$("global.form['username.label']") }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="v$.registerAccount.login.$model"
|
||||
id="username"
|
||||
name="login"
|
||||
:class="{ 'is-valid': !v$.registerAccount.login.$invalid, 'is-invalid': v$.registerAccount.login.$invalid }"
|
||||
required
|
||||
minlength="1"
|
||||
maxlength="50"
|
||||
pattern="^[a-zA-Z0-9!#$&'*+=?^_`{|}~.-]+@?[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$"
|
||||
:placeholder="t$('global.form[\'username.placeholder\']')"
|
||||
data-cy="username"
|
||||
/>
|
||||
<div v-if="v$.registerAccount.login.$anyDirty && v$.registerAccount.login.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.login.required.$invalid">{{
|
||||
t$('register.messages.validate.login.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.login.minLength.$invalid">{{
|
||||
t$('register.messages.validate.login.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.login.maxLength.$invalid">{{
|
||||
t$('register.messages.validate.login.maxlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.login.pattern.$invalid">{{
|
||||
t$('register.messages.validate.login.pattern')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="email">{{ t$("global.form['email.label']") }}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
:class="{ 'is-valid': !v$.registerAccount.email.$invalid, 'is-invalid': v$.registerAccount.email.$invalid }"
|
||||
v-model="v$.registerAccount.email.$model"
|
||||
minlength="5"
|
||||
maxlength="254"
|
||||
email
|
||||
required
|
||||
:placeholder="t$('global.form[\'email.placeholder\']')"
|
||||
data-cy="email"
|
||||
/>
|
||||
<div v-if="v$.registerAccount.email.$anyDirty && v$.registerAccount.email.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.email.required.$invalid">{{
|
||||
t$('global.messages.validate.email.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.email.email.$invalid">{{
|
||||
t$('global.messages.validate.email.invalid')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.email.minLength.$invalid">{{
|
||||
t$('global.messages.validate.email.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.email.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.email.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="firstPassword">{{ t$("global.form['newpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="firstPassword"
|
||||
name="password"
|
||||
:class="{ 'is-valid': !v$.registerAccount.password.$invalid, 'is-invalid': v$.registerAccount.password.$invalid }"
|
||||
v-model="v$.registerAccount.password.$model"
|
||||
minlength="4"
|
||||
maxlength="50"
|
||||
required
|
||||
:placeholder="t$('global.form[\'newpassword.placeholder\']')"
|
||||
data-cy="firstPassword"
|
||||
/>
|
||||
<div v-if="v$.registerAccount.password.$anyDirty && v$.registerAccount.password.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.password.required.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.password.minLength.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.registerAccount.password.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="secondPassword">{{ t$("global.form['confirmpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="secondPassword"
|
||||
name="confirmPasswordInput"
|
||||
:class="{ 'is-valid': !v$.confirmPassword.$invalid, 'is-invalid': v$.confirmPassword.$invalid }"
|
||||
v-model="v$.confirmPassword.$model"
|
||||
minlength="4"
|
||||
maxlength="50"
|
||||
required
|
||||
:placeholder="t$('global.form[\'confirmpassword.placeholder\']')"
|
||||
data-cy="secondPassword"
|
||||
/>
|
||||
<div v-if="v$.confirmPassword.$dirty && v$.confirmPassword.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.confirmPassword.required.$invalid">{{
|
||||
t$('global.messages.validate.confirmpassword.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.confirmPassword.minLength.$invalid">{{
|
||||
t$('global.messages.validate.confirmpassword.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.confirmPassword.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.confirmpassword.maxlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.confirmPassword.sameAsPassword">{{
|
||||
t$('global.messages.error.dontmatch')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="v$.$invalid" class="btn btn-primary" data-cy="submit">{{ t$('register.form.button') }}</button>
|
||||
</form>
|
||||
<p></p>
|
||||
<div class="alert alert-warning">
|
||||
<span>{{ t$('global.messages.info.authenticated.prefix') }}</span>
|
||||
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a
|
||||
><span v-html="t$('global.messages.info.authenticated.suffix')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./register.component.ts"></script>
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import ResetPasswordFinish from './reset-password-finish.vue';
|
||||
|
||||
type ResetPasswordFinishComponentType = InstanceType<typeof ResetPasswordFinish>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Reset Component Finish', () => {
|
||||
let resetPasswordFinish: ResetPasswordFinishComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.post.reset();
|
||||
const wrapper = shallowMount(ResetPasswordFinish, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
},
|
||||
});
|
||||
resetPasswordFinish = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should reset finish be a success', async () => {
|
||||
// Given
|
||||
axiosStub.post.resolves();
|
||||
|
||||
// When
|
||||
await resetPasswordFinish.finishReset();
|
||||
|
||||
// Then
|
||||
expect(resetPasswordFinish.success).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reset request fail as an error', async () => {
|
||||
// Given
|
||||
axiosStub.post.rejects({
|
||||
response: {
|
||||
status: null,
|
||||
data: {
|
||||
type: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await resetPasswordFinish.finishReset();
|
||||
await resetPasswordFinish.$nextTick();
|
||||
|
||||
// Then
|
||||
expect(resetPasswordFinish.success).toBeNull();
|
||||
expect(resetPasswordFinish.error).toEqual('ERROR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type Ref, defineComponent, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { maxLength, minLength, required, sameAs } from '@vuelidate/validators';
|
||||
import axios from 'axios';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ResetPasswordFinish',
|
||||
validations() {
|
||||
return {
|
||||
resetAccount: {
|
||||
newPassword: {
|
||||
required,
|
||||
minLength: minLength(4),
|
||||
maxLength: maxLength(254),
|
||||
},
|
||||
confirmPassword: {
|
||||
sameAsPassword: sameAs(this.resetAccount.newPassword),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
setup() {
|
||||
const { showLogin } = useLoginModal();
|
||||
|
||||
const doNotMatch: Ref<string> = ref(null);
|
||||
const success: Ref<string> = ref(null);
|
||||
const error: Ref<string> = ref(null);
|
||||
const keyMissing: Ref<boolean> = ref(false);
|
||||
const key: Ref<any> = ref(null);
|
||||
const resetAccount: Ref<any> = ref({
|
||||
newPassword: null,
|
||||
confirmPassword: null,
|
||||
});
|
||||
|
||||
return {
|
||||
showLogin,
|
||||
doNotMatch,
|
||||
success,
|
||||
error,
|
||||
keyMissing,
|
||||
key,
|
||||
resetAccount,
|
||||
v$: useVuelidate(),
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
created(): void {
|
||||
if (this.$route?.query?.key !== undefined) {
|
||||
this.key = this.$route.query.key;
|
||||
}
|
||||
this.keyMissing = !this.key;
|
||||
},
|
||||
methods: {
|
||||
finishReset() {
|
||||
this.doNotMatch = null;
|
||||
this.success = null;
|
||||
this.error = null;
|
||||
if (this.resetAccount.newPassword !== this.resetAccount.confirmPassword) {
|
||||
this.doNotMatch = 'ERROR';
|
||||
} else {
|
||||
return axios
|
||||
.post('api/account/reset-password/finish', { key: this.key, newPassword: this.resetAccount.newPassword })
|
||||
.then(() => {
|
||||
this.success = 'OK';
|
||||
})
|
||||
.catch(() => {
|
||||
this.success = null;
|
||||
this.error = 'ERROR';
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<h1>{{ t$('reset.request.title') }}</h1>
|
||||
|
||||
<div class="alert alert-danger" v-html="t$('reset.finish.messages.keymissing')" v-if="keyMissing"></div>
|
||||
|
||||
<div class="alert alert-danger" v-if="error">
|
||||
<p>{{ t$('reset.finish.messages.error') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" v-if="success">
|
||||
<span v-html="t$('reset.finish.messages.success')"></span>
|
||||
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a>
|
||||
</div>
|
||||
<div class="alert alert-danger" v-if="doNotMatch">
|
||||
<p>{{ t$('global.messages.error.dontmatch') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" v-if="!success && !keyMissing">
|
||||
<p>{{ t$('reset.finish.messages.info') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!keyMissing">
|
||||
<form v-if="!success" name="form" @submit.prevent="finishReset()">
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="newPassword">{{ t$("global.form['newpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="newPassword"
|
||||
name="newPassword"
|
||||
:placeholder="t$('global.form[\'newpassword.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.resetAccount.newPassword.$invalid, 'is-invalid': v$.resetAccount.newPassword.$invalid }"
|
||||
v-model="v$.resetAccount.newPassword.$model"
|
||||
minlength="4"
|
||||
maxlength="50"
|
||||
required
|
||||
data-cy="resetPassword"
|
||||
/>
|
||||
<div v-if="v$.resetAccount.newPassword.$anyDirty && v$.resetAccount.newPassword.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.newPassword.required.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.newPassword.minLength.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.newPassword.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.newpassword.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="confirmPassword">{{ t$("global.form['confirmpassword.label']") }}</label>
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
:class="{ 'is-valid': !v$.resetAccount.confirmPassword.$invalid, 'is-invalid': v$.resetAccount.confirmPassword.$invalid }"
|
||||
:placeholder="t$('global.form[\'confirmpassword.placeholder\']')"
|
||||
v-model="v$.resetAccount.confirmPassword.$model"
|
||||
minlength="4"
|
||||
maxlength="50"
|
||||
required
|
||||
data-cy="confirmResetPassword"
|
||||
/>
|
||||
<div v-if="v$.resetAccount.confirmPassword.$anyDirty && v$.resetAccount.confirmPassword.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.confirmPassword.sameAsPassword.$invalid">{{
|
||||
t$('global.messages.error.dontmatch')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="v$.resetAccount.$invalid" class="btn btn-primary" data-cy="submit">
|
||||
{{ t$('password.form.button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./reset-password-finish.component.ts"></script>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import ResetPasswordInit from './reset-password-init.vue';
|
||||
|
||||
type ResetPasswordInitComponentType = InstanceType<typeof ResetPasswordInit>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Reset Component Init', () => {
|
||||
let resetPasswordInit: ResetPasswordInitComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.post.reset();
|
||||
const wrapper = shallowMount(ResetPasswordInit, {});
|
||||
resetPasswordInit = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should reset request be a success', async () => {
|
||||
// Given
|
||||
axiosStub.post.resolves();
|
||||
|
||||
// When
|
||||
await resetPasswordInit.requestReset();
|
||||
|
||||
// Then
|
||||
expect(resetPasswordInit.success).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should reset request fail as an error', async () => {
|
||||
// Given
|
||||
axiosStub.post.rejects({
|
||||
response: {
|
||||
status: null,
|
||||
data: {
|
||||
type: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// When
|
||||
await resetPasswordInit.requestReset();
|
||||
await resetPasswordInit.$nextTick();
|
||||
|
||||
// Then
|
||||
expect(resetPasswordInit.success).toBe(false);
|
||||
expect(resetPasswordInit.error).toEqual('ERROR');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { type Ref, defineComponent, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { email, maxLength, minLength, required } from '@vuelidate/validators';
|
||||
import axios from 'axios';
|
||||
|
||||
const validations = {
|
||||
resetAccount: {
|
||||
email: {
|
||||
required,
|
||||
minLength: minLength(5),
|
||||
maxLength: maxLength(254),
|
||||
email,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface ResetAccount {
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ResetPasswordInit',
|
||||
validations,
|
||||
setup() {
|
||||
const error: Ref<string> = ref(null);
|
||||
const success: Ref<boolean> = ref(false);
|
||||
const resetAccount: Ref<ResetAccount> = ref({
|
||||
email: null,
|
||||
});
|
||||
|
||||
return {
|
||||
error,
|
||||
success,
|
||||
resetAccount,
|
||||
v$: useVuelidate(),
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async requestReset(): Promise<void> {
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
await axios
|
||||
.post('api/account/reset-password/init', this.resetAccount.email, {
|
||||
headers: {
|
||||
'content-type': 'text/plain',
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.success = true;
|
||||
})
|
||||
.catch(() => {
|
||||
this.success = false;
|
||||
this.error = 'ERROR';
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<h1>{{ t$('reset.request.title') }}</h1>
|
||||
|
||||
<div class="alert alert-warning" v-if="!success">
|
||||
<p>{{ t$('reset.request.messages.info') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-success" v-if="success">
|
||||
<p>{{ t$('reset.request.messages.success') }}</p>
|
||||
</div>
|
||||
|
||||
<form v-if="!success" name="form" @submit.prevent="requestReset()">
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="email">{{ t$("global.form['email.label']") }}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
:placeholder="t$('global.form[\'email.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.resetAccount.email.$invalid, 'is-invalid': v$.resetAccount.email.$invalid }"
|
||||
v-model="v$.resetAccount.email.$model"
|
||||
minlength="5"
|
||||
maxlength="254"
|
||||
email
|
||||
required
|
||||
data-cy="emailResetPassword"
|
||||
/>
|
||||
<div v-if="v$.resetAccount.email.$anyDirty && v$.resetAccount.email.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.email.required.$invalid">{{
|
||||
t$('global.messages.validate.email.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.email.email.$invalid">{{
|
||||
t$('global.messages.validate.email.invalid')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.email.minLength.$invalid">{{
|
||||
t$('global.messages.validate.email.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.resetAccount.email.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.email.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" :disabled="v$.resetAccount.$invalid" class="btn btn-primary" data-cy="submit">
|
||||
{{ t$('reset.request.form.button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./reset-password-init.component.ts"></script>
|
||||
@@ -0,0 +1,80 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { useStore } from '@/store';
|
||||
|
||||
import Sessions from './sessions.vue';
|
||||
|
||||
type SessionsComponentType = InstanceType<typeof Sessions>;
|
||||
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
delete: sinon.stub(axios, 'delete'),
|
||||
};
|
||||
|
||||
describe('Sessions Component', () => {
|
||||
let sessions: SessionsComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.reset();
|
||||
axiosStub.get.resolves({ data: [] });
|
||||
axiosStub.delete.reset();
|
||||
|
||||
store.setAuthentication({
|
||||
login: 'username',
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(Sessions, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
sessions = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should call remote service on init', () => {
|
||||
expect(axiosStub.get.callCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('should have good username', () => {
|
||||
expect(sessions.username).toEqual('username');
|
||||
});
|
||||
|
||||
it('should invalidate a session', async () => {
|
||||
// Given
|
||||
axiosStub.get.reset();
|
||||
axiosStub.get.resolves({ data: [] });
|
||||
axiosStub.delete.resolves();
|
||||
|
||||
// When
|
||||
await sessions.invalidate('session');
|
||||
await sessions.$nextTick();
|
||||
|
||||
// Then
|
||||
expect(sessions.error).toBeNull();
|
||||
expect(sessions.success).toEqual('OK');
|
||||
expect(axiosStub.get.callCount).toEqual(1);
|
||||
});
|
||||
|
||||
it('should fail to invalidate session', async () => {
|
||||
// Given
|
||||
axiosStub.get.reset();
|
||||
axiosStub.get.resolves({ data: [] });
|
||||
axiosStub.delete.rejects();
|
||||
|
||||
// When
|
||||
await sessions.invalidate('session');
|
||||
await sessions.$nextTick();
|
||||
|
||||
// Then
|
||||
expect(sessions.success).toBeNull();
|
||||
expect(sessions.error).toEqual('ERROR');
|
||||
expect(axiosStub.get.callCount).toEqual(0);
|
||||
});
|
||||
});
|
||||
53
src/main/webapp/app/account/sessions/sessions.component.ts
Normal file
53
src/main/webapp/app/account/sessions/sessions.component.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { type Ref, computed, defineComponent, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
import { useStore } from '@/store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Sessions',
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const success: Ref<string> = ref(null);
|
||||
const error: Ref<string> = ref(null);
|
||||
const sessions: Ref<any[]> = ref([]);
|
||||
|
||||
const authenticated = computed(() => store.authenticated);
|
||||
const username = computed(() => store.account?.login ?? '');
|
||||
|
||||
return {
|
||||
success,
|
||||
error,
|
||||
sessions,
|
||||
authenticated,
|
||||
username,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
created(): void {
|
||||
this.retrieveSessions();
|
||||
},
|
||||
methods: {
|
||||
retrieveSessions() {
|
||||
return axios.get('api/account/sessions').then(response => {
|
||||
this.error = null;
|
||||
this.sessions = response.data;
|
||||
});
|
||||
},
|
||||
invalidate(session) {
|
||||
return axios
|
||||
.delete(`api/account/sessions/${session}`)
|
||||
.then(() => {
|
||||
this.error = null;
|
||||
this.success = 'OK';
|
||||
this.retrieveSessions();
|
||||
})
|
||||
.catch(() => {
|
||||
this.success = null;
|
||||
this.error = 'ERROR';
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
38
src/main/webapp/app/account/sessions/sessions.vue
Normal file
38
src/main/webapp/app/account/sessions/sessions.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="authenticated">
|
||||
<span
|
||||
>{{ t$('sessions.title') }}<strong>{{ username }}</strong
|
||||
>]</span
|
||||
>
|
||||
</h2>
|
||||
|
||||
<div class="alert alert-success" v-if="success" v-html="t$('sessions.messages.success')"></div>
|
||||
<div class="alert alert-danger" v-if="error" v-html="t$('sessions.messages.error')"></div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t$('sessions.table.ipaddress') }}</th>
|
||||
<th>{{ t$('sessions.table.useragent') }}</th>
|
||||
<th>{{ t$('sessions.table.date') }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="session in sessions" :key="session.ipAddress">
|
||||
<td>{{ session.ipAddress }}</td>
|
||||
<td>{{ session.userAgent }}</td>
|
||||
<td>{{ session.tokenDate }}</td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-primary" @click="invalidate(session.series)">{{ t$('sessions.table.button') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./sessions.component.ts"></script>
|
||||
112
src/main/webapp/app/account/settings/settings.component.spec.ts
Normal file
112
src/main/webapp/app/account/settings/settings.component.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { EMAIL_ALREADY_USED_TYPE } from '@/constants';
|
||||
import { useStore } from '@/store';
|
||||
|
||||
import Settings from './settings.vue';
|
||||
|
||||
type SettingsComponentType = InstanceType<typeof Settings>;
|
||||
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Settings Component', () => {
|
||||
let settings: SettingsComponentType;
|
||||
const account = {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john.doe@jhipster.org',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.post.reset();
|
||||
|
||||
store.setAuthentication(account);
|
||||
const wrapper = shallowMount(Settings, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
settings = wrapper.vm;
|
||||
});
|
||||
|
||||
it('should send the current identity upon save', async () => {
|
||||
// GIVEN
|
||||
axiosStub.post.resolves({});
|
||||
|
||||
// WHEN
|
||||
await settings.save();
|
||||
await settings.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.post.calledWith('api/account', account)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should notify of success upon successful save', async () => {
|
||||
// GIVEN
|
||||
axiosStub.post.resolves(account);
|
||||
|
||||
// WHEN
|
||||
await settings.save();
|
||||
await settings.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(settings.error).toBeNull();
|
||||
expect(settings.success).toBe('OK');
|
||||
});
|
||||
|
||||
it('should notify of error upon failed save', async () => {
|
||||
// GIVEN
|
||||
const error = { response: { status: 417 } };
|
||||
axiosStub.post.rejects(error);
|
||||
|
||||
// WHEN
|
||||
await settings.save();
|
||||
await settings.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(settings.error).toEqual('ERROR');
|
||||
expect(settings.errorEmailExists).toBeNull();
|
||||
expect(settings.success).toBeNull();
|
||||
});
|
||||
|
||||
it('should notify of error upon error 400', async () => {
|
||||
// GIVEN
|
||||
const error = { response: { status: 400, data: {} } };
|
||||
axiosStub.post.rejects(error);
|
||||
|
||||
// WHEN
|
||||
await settings.save();
|
||||
await settings.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(settings.error).toEqual('ERROR');
|
||||
expect(settings.errorEmailExists).toBeNull();
|
||||
expect(settings.success).toBeNull();
|
||||
});
|
||||
|
||||
it('should notify of error upon email already used', async () => {
|
||||
// GIVEN
|
||||
const error = { response: { status: 400, data: { type: EMAIL_ALREADY_USED_TYPE } } };
|
||||
axiosStub.post.rejects(error);
|
||||
|
||||
// WHEN
|
||||
await settings.save();
|
||||
await settings.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(settings.errorEmailExists).toEqual('ERROR');
|
||||
expect(settings.error).toBeNull();
|
||||
expect(settings.success).toBeNull();
|
||||
});
|
||||
});
|
||||
78
src/main/webapp/app/account/settings/settings.component.ts
Normal file
78
src/main/webapp/app/account/settings/settings.component.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type ComputedRef, type Ref, computed, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { email, maxLength, minLength, required } from '@vuelidate/validators';
|
||||
import axios from 'axios';
|
||||
|
||||
import { EMAIL_ALREADY_USED_TYPE } from '@/constants';
|
||||
import languages from '@/shared/config/languages';
|
||||
import { useStore } from '@/store';
|
||||
|
||||
const validations = {
|
||||
settingsAccount: {
|
||||
firstName: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
maxLength: maxLength(50),
|
||||
},
|
||||
lastName: {
|
||||
required,
|
||||
minLength: minLength(1),
|
||||
maxLength: maxLength(50),
|
||||
},
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
minLength: minLength(5),
|
||||
maxLength: maxLength(254),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Settings',
|
||||
validations,
|
||||
setup() {
|
||||
const store = useStore();
|
||||
|
||||
const success: Ref<string> = ref(null);
|
||||
const error: Ref<string> = ref(null);
|
||||
const errorEmailExists: Ref<string> = ref(null);
|
||||
|
||||
const settingsAccount = computed(() => store.account);
|
||||
const username = inject<ComputedRef<string>>('currentUsername', () => computed(() => store.account?.login), true);
|
||||
|
||||
return {
|
||||
success,
|
||||
error,
|
||||
errorEmailExists,
|
||||
settingsAccount,
|
||||
username,
|
||||
v$: useVuelidate(),
|
||||
languages: languages(),
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
save() {
|
||||
this.error = null;
|
||||
this.errorEmailExists = null;
|
||||
return axios
|
||||
.post('api/account', this.settingsAccount)
|
||||
.then(() => {
|
||||
this.error = null;
|
||||
this.success = 'OK';
|
||||
this.errorEmailExists = null;
|
||||
})
|
||||
.catch(ex => {
|
||||
this.success = null;
|
||||
this.error = 'ERROR';
|
||||
if (ex.response.status === 400 && ex.response.data.type === EMAIL_ALREADY_USED_TYPE) {
|
||||
this.errorEmailExists = 'ERROR';
|
||||
this.error = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
114
src/main/webapp/app/account/settings/settings.vue
Normal file
114
src/main/webapp/app/account/settings/settings.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-md-8 toastify-container">
|
||||
<h2 v-if="username" id="settings-title">
|
||||
<span v-html="t$('settings.title', { username })"></span>
|
||||
</h2>
|
||||
|
||||
<div class="alert alert-success" role="alert" v-if="success" v-html="t$('settings.messages.success')"></div>
|
||||
|
||||
<div class="alert alert-danger" role="alert" v-if="errorEmailExists" v-html="t$('register.messages.error.emailexists')"></div>
|
||||
|
||||
<form name="form" id="settings-form" @submit.prevent="save()" v-if="settingsAccount" novalidate>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="firstName">{{ t$('settings.form.firstname') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
:placeholder="t$('settings.form[\'firstname.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.settingsAccount.firstName.$invalid, 'is-invalid': v$.settingsAccount.firstName.$invalid }"
|
||||
v-model="v$.settingsAccount.firstName.$model"
|
||||
minlength="1"
|
||||
maxlength="50"
|
||||
required
|
||||
data-cy="firstname"
|
||||
/>
|
||||
<div v-if="v$.settingsAccount.firstName.$anyDirty && v$.settingsAccount.firstName.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.firstName.required.$invalid">{{
|
||||
t$('settings.messages.validate.firstname.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.firstName.minLength.$invalid">{{
|
||||
t$('settings.messages.validate.firstname.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.firstName.maxLength.$invalid">{{
|
||||
t$('settings.messages.validate.firstname.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="lastName">{{ t$('settings.form.lastname') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
:placeholder="t$('settings.form[\'lastname.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.settingsAccount.lastName.$invalid, 'is-invalid': v$.settingsAccount.lastName.$invalid }"
|
||||
v-model="v$.settingsAccount.lastName.$model"
|
||||
minlength="1"
|
||||
maxlength="50"
|
||||
required
|
||||
data-cy="lastname"
|
||||
/>
|
||||
<div v-if="v$.settingsAccount.lastName.$anyDirty && v$.settingsAccount.lastName.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.lastName.required.$invalid">{{
|
||||
t$('settings.messages.validate.lastname.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.lastName.minLength.$invalid">{{
|
||||
t$('settings.messages.validate.lastname.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.lastName.maxLength.$invalid">{{
|
||||
t$('settings.messages.validate.lastname.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="email">{{ t$("global.form['email.label']") }}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
:placeholder="t$('global.form[\'email.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.settingsAccount.email.$invalid, 'is-invalid': v$.settingsAccount.email.$invalid }"
|
||||
v-model="v$.settingsAccount.email.$model"
|
||||
minlength="5"
|
||||
maxlength="254"
|
||||
email
|
||||
required
|
||||
data-cy="email"
|
||||
/>
|
||||
<div v-if="v$.settingsAccount.email.$anyDirty && v$.settingsAccount.email.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.email.required.$invalid">{{
|
||||
t$('global.messages.validate.email.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.email.email.$invalid">{{
|
||||
t$('global.messages.validate.email.invalid')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.email.minLength.$invalid">{{
|
||||
t$('global.messages.validate.email.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.settingsAccount.email.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.email.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3" v-if="languages && Object.keys(languages).length > 1">
|
||||
<label for="langKey">{{ t$('settings.form.language') }}</label>
|
||||
<select class="form-control" id="langKey" name="langKey" v-model="settingsAccount.langKey" data-cy="langKey">
|
||||
<option v-for="(language, key) in languages" :value="key" :key="`lang-${key}`">{{ language.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" :disabled="v$.settingsAccount.$invalid" class="btn btn-primary" data-cy="submit">
|
||||
{{ t$('settings.form.button') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./settings.component.ts"></script>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import Configuration from './configuration.vue';
|
||||
|
||||
type ConfigurationComponentType = InstanceType<typeof Configuration>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
};
|
||||
|
||||
describe('Configuration Component', () => {
|
||||
let configuration: ConfigurationComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.reset();
|
||||
axiosStub.get.resolves({
|
||||
data: { contexts: [{ beans: [{ prefix: 'A' }, { prefix: 'B' }] }], propertySources: [{ properties: { key1: { value: 'value' } } }] },
|
||||
});
|
||||
const wrapper = shallowMount(Configuration, {
|
||||
global: {
|
||||
stubs: {
|
||||
'jhi-sort-indicator': true,
|
||||
},
|
||||
},
|
||||
});
|
||||
configuration = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('OnRouteEnter', () => {
|
||||
it('should set all default values correctly', () => {
|
||||
expect(configuration.configKeys).toEqual([]);
|
||||
expect(configuration.filtered).toBe('');
|
||||
expect(configuration.orderProp).toBe('prefix');
|
||||
expect(configuration.reverse).toBe(false);
|
||||
});
|
||||
it('Should call load all on init', async () => {
|
||||
// WHEN
|
||||
configuration.init();
|
||||
await configuration.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('management/env')).toBeTruthy();
|
||||
expect(axiosStub.get.calledWith('management/configprops')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('keys method', () => {
|
||||
it('should return the keys of an Object', () => {
|
||||
// GIVEN
|
||||
const data = {
|
||||
key1: 'test',
|
||||
key2: 'test2',
|
||||
};
|
||||
|
||||
// THEN
|
||||
expect(configuration.keys(data)).toEqual(['key1', 'key2']);
|
||||
expect(configuration.keys(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeOrder function', () => {
|
||||
it('should change order', () => {
|
||||
// GIVEN
|
||||
const rev = configuration.reverse;
|
||||
|
||||
// WHEN
|
||||
configuration.changeOrder('prefix');
|
||||
|
||||
// THEN
|
||||
expect(configuration.orderProp).toBe('prefix');
|
||||
expect(configuration.reverse).toBe(!rev);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { orderAndFilterBy } from '@/shared/computables';
|
||||
|
||||
import ConfigurationService from './configuration.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiConfiguration',
|
||||
setup() {
|
||||
const configurationService = inject('configurationService', () => new ConfigurationService(), true);
|
||||
|
||||
const orderProp = ref('prefix');
|
||||
const reverse = ref(false);
|
||||
const allConfiguration: Ref<any> = ref({});
|
||||
const configuration: Ref<any[]> = ref([]);
|
||||
const configKeys: Ref<any[]> = ref([]);
|
||||
const filtered = ref('');
|
||||
|
||||
const filteredConfiguration = computed(() =>
|
||||
orderAndFilterBy(configuration.value, {
|
||||
filterByTerm: filtered.value,
|
||||
orderByProp: orderProp.value,
|
||||
reverse: reverse.value,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
configurationService,
|
||||
orderProp,
|
||||
reverse,
|
||||
allConfiguration,
|
||||
configuration,
|
||||
configKeys,
|
||||
filtered,
|
||||
filteredConfiguration,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
methods: {
|
||||
init(): void {
|
||||
this.configurationService.loadConfiguration().then(res => {
|
||||
this.configuration = res;
|
||||
|
||||
for (const config of this.configuration) {
|
||||
if (config.properties !== undefined) {
|
||||
this.configKeys.push(Object.keys(config.properties));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.configurationService.loadEnvConfiguration().then(res => {
|
||||
this.allConfiguration = res;
|
||||
});
|
||||
},
|
||||
changeOrder(prop: string): void {
|
||||
this.orderProp = prop;
|
||||
this.reverse = !this.reverse;
|
||||
},
|
||||
keys(dict: any): string[] {
|
||||
return dict === undefined ? [] : Object.keys(dict);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import axios from 'axios';
|
||||
|
||||
export default class ConfigurationService {
|
||||
async loadConfiguration(): Promise<any> {
|
||||
const res = await axios.get('management/configprops');
|
||||
const properties = [];
|
||||
const propertiesObject = this.getConfigPropertiesObjects(res.data);
|
||||
for (const key in propertiesObject) {
|
||||
if (Object.hasOwn(propertiesObject, key)) {
|
||||
properties.push(propertiesObject[key]);
|
||||
}
|
||||
}
|
||||
|
||||
properties.sort((propertyA, propertyB) => {
|
||||
const comparePrefix = propertyA.prefix < propertyB.prefix ? -1 : 1;
|
||||
return propertyA.prefix === propertyB.prefix ? 0 : comparePrefix;
|
||||
});
|
||||
return properties;
|
||||
}
|
||||
|
||||
async loadEnvConfiguration(): Promise<any> {
|
||||
const res = await axios.get<any>('management/env');
|
||||
const properties = {};
|
||||
const propertySources = res.data.propertySources;
|
||||
|
||||
for (const propertyObject of propertySources) {
|
||||
const name = propertyObject.name;
|
||||
const detailProperties = propertyObject.properties;
|
||||
const vals = [];
|
||||
for (const keyDetail in detailProperties) {
|
||||
if (Object.hasOwn(detailProperties, keyDetail)) {
|
||||
vals.push({ key: keyDetail, val: detailProperties[keyDetail].value });
|
||||
}
|
||||
}
|
||||
properties[name] = vals;
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
private getConfigPropertiesObjects(res): any {
|
||||
// This code is for Spring Boot 2
|
||||
if (res.contexts !== undefined) {
|
||||
for (const key in res.contexts) {
|
||||
// If the key is not bootstrap, it will be the ApplicationContext Id
|
||||
// For default app, it is baseName
|
||||
// For microservice, it is baseName-1
|
||||
if (!key.startsWith('bootstrap')) {
|
||||
return res.contexts[key].beans;
|
||||
}
|
||||
}
|
||||
}
|
||||
// by default, use the default ApplicationContext Id
|
||||
return res.contexts.smartbooking.beans;
|
||||
}
|
||||
}
|
||||
62
src/main/webapp/app/admin/configuration/configuration.vue
Normal file
62
src/main/webapp/app/admin/configuration/configuration.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 id="configuration-page-heading" data-cy="configurationPageHeading">{{ t$('configuration.title') }}</h2>
|
||||
|
||||
<div v-if="allConfiguration && configuration">
|
||||
<span>{{ t$('configuration.filter') }}</span> <input type="text" v-model="filtered" class="form-control" />
|
||||
<h3>Spring configuration</h3>
|
||||
<table class="table table-striped table-bordered table-responsive d-table" aria-describedby="Configuration">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-40" @click="changeOrder('prefix')" scope="col">
|
||||
<span>{{ t$('configuration.table.prefix') }}</span>
|
||||
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'prefix'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th class="w-60" @click="changeOrder('properties')" scope="col">
|
||||
<span>{{ t$('configuration.table.properties') }}</span>
|
||||
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'properties'"></jhi-sort-indicator>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in filteredConfiguration" :key="entry.prefix">
|
||||
<td>
|
||||
<span>{{ entry.prefix }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row" v-for="key in keys(entry.properties)" :key="key">
|
||||
<div class="col-md-4">{{ key }}</div>
|
||||
<div class="col-md-8">
|
||||
<span class="float-end bg-secondary break">{{ entry.properties[key] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-for="key in keys(allConfiguration)" :key="key">
|
||||
<h4>
|
||||
<span>{{ key }}</span>
|
||||
</h4>
|
||||
<table class="table table-sm table-striped table-bordered table-responsive d-table" aria-describedby="Properties">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-40" scope="col">Property</th>
|
||||
<th class="w-60" scope="col">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item of allConfiguration[key]" :key="item.key">
|
||||
<td class="break">{{ item.key }}</td>
|
||||
<td class="break">
|
||||
<span class="float-end bg-secondary break">{{ item.val }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./configuration.component.ts"></script>
|
||||
5
src/main/webapp/app/admin/docs/docs.component.ts
Normal file
5
src/main/webapp/app/admin/docs/docs.component.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiDocs',
|
||||
});
|
||||
14
src/main/webapp/app/admin/docs/docs.vue
Normal file
14
src/main/webapp/app/admin/docs/docs.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<iframe
|
||||
src="/swagger-ui/index.html"
|
||||
width="100%"
|
||||
height="900"
|
||||
seamless
|
||||
target="_top"
|
||||
title="Swagger UI"
|
||||
class="border-0"
|
||||
data-cy="swagger-frame"
|
||||
></iframe>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./docs.component.ts"></script>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { vitest } from 'vitest';
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import HealthModal from './health-modal.vue';
|
||||
|
||||
type HealthModalComponentType = InstanceType<typeof HealthModal>;
|
||||
|
||||
const healthService = { getBaseName: vitest.fn(), getSubSystemName: vitest.fn() };
|
||||
|
||||
describe('Health Modal Component', () => {
|
||||
let healthModal: HealthModalComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
const wrapper = shallowMount(HealthModal, {
|
||||
propsData: {
|
||||
currentHealth: {},
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
'font-awesome-icon': true,
|
||||
},
|
||||
provide: {
|
||||
healthService,
|
||||
},
|
||||
},
|
||||
});
|
||||
healthModal = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('baseName and subSystemName', () => {
|
||||
it('should use healthService', () => {
|
||||
healthModal.baseName('base');
|
||||
|
||||
expect(healthService.getBaseName).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use healthService', () => {
|
||||
healthModal.subSystemName('base');
|
||||
|
||||
expect(healthService.getSubSystemName).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('readableValue should transform data', () => {
|
||||
it('to string when is an object', () => {
|
||||
const result = healthModal.readableValue({ data: 1000 });
|
||||
|
||||
expect(result).toBe('{"data":1000}');
|
||||
});
|
||||
|
||||
it('to string when is a string', () => {
|
||||
const result = healthModal.readableValue('data');
|
||||
|
||||
expect(result).toBe('data');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Health Modal Component for diskSpace', () => {
|
||||
let healthModal: HealthModalComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
const wrapper = shallowMount(HealthModal, {
|
||||
propsData: {
|
||||
currentHealth: { name: 'diskSpace' },
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
healthService,
|
||||
},
|
||||
},
|
||||
});
|
||||
healthModal = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('readableValue should transform data', () => {
|
||||
it('to GB when needed', () => {
|
||||
const result = healthModal.readableValue(2147483648);
|
||||
|
||||
expect(result).toBe('2.00 GB');
|
||||
});
|
||||
|
||||
it('to MB when needed', () => {
|
||||
const result = healthModal.readableValue(214748);
|
||||
|
||||
expect(result).toBe('0.20 MB');
|
||||
});
|
||||
});
|
||||
});
|
||||
46
src/main/webapp/app/admin/health/health-modal.component.ts
Normal file
46
src/main/webapp/app/admin/health/health-modal.component.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { defineComponent, inject } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import HealthService from './health.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiHealthModal',
|
||||
props: {
|
||||
currentHealth: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const healthService = inject('healthService', () => new HealthService(), true);
|
||||
|
||||
return {
|
||||
healthService,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
baseName(name: string): any {
|
||||
return this.healthService.getBaseName(name);
|
||||
},
|
||||
subSystemName(name: string): any {
|
||||
return this.healthService.getSubSystemName(name);
|
||||
},
|
||||
readableValue(value: any): string {
|
||||
if (this.currentHealth.name === 'diskSpace') {
|
||||
// Should display storage space in a human readable unit
|
||||
const val = value / 1073741824;
|
||||
if (val > 1) {
|
||||
// Value
|
||||
return `${val.toFixed(2)} GB`;
|
||||
}
|
||||
return `${(value / 1048576).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return value.toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
29
src/main/webapp/app/admin/health/health-modal.vue
Normal file
29
src/main/webapp/app/admin/health/health-modal.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="modal-body pad">
|
||||
<div v-if="currentHealth?.details">
|
||||
<h5>{{ t$('health.details.properties') }}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" aria-describedby="Health">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-start" scope="col">{{ t$('health.details.name') }}</th>
|
||||
<th class="text-start" scope="col">{{ t$('health.details.value') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in currentHealth.details.details" :key="index">
|
||||
<td class="text-start">{{ index }}</td>
|
||||
<td class="text-start">{{ readableValue(item) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentHealth?.error">
|
||||
<h4>{{ t$('health.details.error') }}</h4>
|
||||
<pre>{{ currentHealth.error }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./health-modal.component.ts"></script>
|
||||
92
src/main/webapp/app/admin/health/health.component.spec.ts
Normal file
92
src/main/webapp/app/admin/health/health.component.spec.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import HealthService from './health.service';
|
||||
import Health from './health.vue';
|
||||
|
||||
type HealthComponentType = InstanceType<typeof Health>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
};
|
||||
|
||||
describe('Health Component', () => {
|
||||
let health: HealthComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.resolves({});
|
||||
const wrapper = shallowMount(Health, {
|
||||
global: {
|
||||
stubs: {
|
||||
bModal: true,
|
||||
'font-awesome-icon': true,
|
||||
'health-modal': true,
|
||||
},
|
||||
directives: {
|
||||
'b-modal': {},
|
||||
},
|
||||
provide: {
|
||||
healthService: new HealthService(),
|
||||
},
|
||||
},
|
||||
});
|
||||
health = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('baseName and subSystemName', () => {
|
||||
it('should return the basename when it has no sub system', () => {
|
||||
expect(health.baseName('base')).toBe('base');
|
||||
});
|
||||
|
||||
it('should return the basename when it has sub systems', () => {
|
||||
expect(health.baseName('base.subsystem.system')).toBe('base');
|
||||
});
|
||||
|
||||
it('should return the sub system name', () => {
|
||||
expect(health.subSystemName('subsystem')).toBe('');
|
||||
});
|
||||
|
||||
it('should return the subsystem when it has multiple keys', () => {
|
||||
expect(health.subSystemName('subsystem.subsystem.system')).toBe(' - subsystem.system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBadgeClass', () => {
|
||||
it('should get badge class', () => {
|
||||
const upBadgeClass = health.getBadgeClass('UP');
|
||||
const downBadgeClass = health.getBadgeClass('DOWN');
|
||||
expect(upBadgeClass).toEqual('bg-success');
|
||||
expect(downBadgeClass).toEqual('bg-danger');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should call refresh on init', async () => {
|
||||
// GIVEN
|
||||
axiosStub.get.resolves({});
|
||||
|
||||
// WHEN
|
||||
health.refresh();
|
||||
await health.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('management/health')).toBeTruthy();
|
||||
await health.$nextTick();
|
||||
expect(health.updatingHealth).toEqual(false);
|
||||
});
|
||||
it('should handle a 503 on refreshing health data', async () => {
|
||||
// GIVEN
|
||||
axiosStub.get.rejects({});
|
||||
|
||||
// WHEN
|
||||
health.refresh();
|
||||
await health.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('management/health')).toBeTruthy();
|
||||
await health.$nextTick();
|
||||
expect(health.updatingHealth).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/main/webapp/app/admin/health/health.component.ts
Normal file
63
src/main/webapp/app/admin/health/health.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import JhiHealthModal from './health-modal.vue';
|
||||
import HealthService from './health.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiHealth',
|
||||
components: {
|
||||
'health-modal': JhiHealthModal,
|
||||
},
|
||||
setup() {
|
||||
const healthService = inject('healthService', () => new HealthService(), true);
|
||||
|
||||
const healthData: Ref<any> = ref(null);
|
||||
const currentHealth: Ref<any> = ref(null);
|
||||
const updatingHealth = ref(false);
|
||||
|
||||
return {
|
||||
healthService,
|
||||
healthData,
|
||||
currentHealth,
|
||||
updatingHealth,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
mounted(): void {
|
||||
this.refresh();
|
||||
},
|
||||
methods: {
|
||||
baseName(name: any): any {
|
||||
return this.healthService.getBaseName(name);
|
||||
},
|
||||
getBadgeClass(statusState: any): string {
|
||||
if (statusState === 'UP') {
|
||||
return 'bg-success';
|
||||
}
|
||||
return 'bg-danger';
|
||||
},
|
||||
refresh(): void {
|
||||
this.updatingHealth = true;
|
||||
this.healthService
|
||||
.checkHealth()
|
||||
.then(res => {
|
||||
this.healthData = this.healthService.transformHealthData(res.data);
|
||||
this.updatingHealth = false;
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.status === 503) {
|
||||
this.healthData = this.healthService.transformHealthData(error.error);
|
||||
}
|
||||
this.updatingHealth = false;
|
||||
});
|
||||
},
|
||||
showHealth(health: any): void {
|
||||
this.currentHealth = health;
|
||||
(<any>this.$refs.healthModal).show();
|
||||
},
|
||||
subSystemName(name: string): string {
|
||||
return this.healthService.getSubSystemName(name);
|
||||
},
|
||||
},
|
||||
});
|
||||
244
src/main/webapp/app/admin/health/health.service.spec.ts
Normal file
244
src/main/webapp/app/admin/health/health.service.spec.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import HealthService from './health.service';
|
||||
|
||||
describe('Health Service', () => {
|
||||
let healthService: HealthService;
|
||||
|
||||
beforeEach(() => {
|
||||
healthService = new HealthService();
|
||||
});
|
||||
|
||||
describe('transformHealthData', () => {
|
||||
it('should flatten empty health data', () => {
|
||||
const data = {};
|
||||
const expected = [];
|
||||
expect(healthService.transformHealthData(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should flatten health data with no subsystems', () => {
|
||||
const data = {
|
||||
components: {
|
||||
status: 'UP',
|
||||
db: {
|
||||
status: 'UP',
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
mail: {
|
||||
status: 'UP',
|
||||
error: 'mail.a.b.c',
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
name: 'db',
|
||||
status: 'UP',
|
||||
details: {
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mail',
|
||||
error: 'mail.a.b.c',
|
||||
status: 'UP',
|
||||
},
|
||||
];
|
||||
expect(healthService.transformHealthData(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should flatten health data with subsystems at level 1, main system has no additional information', () => {
|
||||
const data = {
|
||||
components: {
|
||||
status: 'UP',
|
||||
db: {
|
||||
status: 'UP',
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
mail: {
|
||||
status: 'UP',
|
||||
error: 'mail.a.b.c',
|
||||
},
|
||||
system: {
|
||||
status: 'DOWN',
|
||||
subsystem1: {
|
||||
status: 'UP',
|
||||
property1: 'system.subsystem1.property1',
|
||||
},
|
||||
subsystem2: {
|
||||
status: 'DOWN',
|
||||
error: 'system.subsystem1.error',
|
||||
property2: 'system.subsystem2.property2',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
name: 'db',
|
||||
status: 'UP',
|
||||
details: {
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mail',
|
||||
error: 'mail.a.b.c',
|
||||
status: 'UP',
|
||||
},
|
||||
{
|
||||
name: 'system.subsystem1',
|
||||
status: 'UP',
|
||||
details: {
|
||||
property1: 'system.subsystem1.property1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'system.subsystem2',
|
||||
error: 'system.subsystem1.error',
|
||||
status: 'DOWN',
|
||||
details: {
|
||||
property2: 'system.subsystem2.property2',
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(healthService.transformHealthData(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should flatten health data with subsystems at level 1, main system has additional information', () => {
|
||||
const data = {
|
||||
components: {
|
||||
status: 'UP',
|
||||
db: {
|
||||
status: 'UP',
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
mail: {
|
||||
status: 'UP',
|
||||
error: 'mail.a.b.c',
|
||||
},
|
||||
system: {
|
||||
status: 'DOWN',
|
||||
property1: 'system.property1',
|
||||
subsystem1: {
|
||||
status: 'UP',
|
||||
property1: 'system.subsystem1.property1',
|
||||
},
|
||||
subsystem2: {
|
||||
status: 'DOWN',
|
||||
error: 'system.subsystem1.error',
|
||||
property2: 'system.subsystem2.property2',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
name: 'db',
|
||||
status: 'UP',
|
||||
details: {
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mail',
|
||||
error: 'mail.a.b.c',
|
||||
status: 'UP',
|
||||
},
|
||||
{
|
||||
name: 'system',
|
||||
status: 'DOWN',
|
||||
details: {
|
||||
property1: 'system.property1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'system.subsystem1',
|
||||
status: 'UP',
|
||||
details: {
|
||||
property1: 'system.subsystem1.property1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'system.subsystem2',
|
||||
error: 'system.subsystem1.error',
|
||||
status: 'DOWN',
|
||||
details: {
|
||||
property2: 'system.subsystem2.property2',
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(healthService.transformHealthData(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should flatten health data with subsystems at level 1, main system has additional error', () => {
|
||||
const data = {
|
||||
components: {
|
||||
status: 'UP',
|
||||
db: {
|
||||
status: 'UP',
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
mail: {
|
||||
status: 'UP',
|
||||
error: 'mail.a.b.c',
|
||||
},
|
||||
system: {
|
||||
status: 'DOWN',
|
||||
error: 'show me',
|
||||
subsystem1: {
|
||||
status: 'UP',
|
||||
property1: 'system.subsystem1.property1',
|
||||
},
|
||||
subsystem2: {
|
||||
status: 'DOWN',
|
||||
error: 'system.subsystem1.error',
|
||||
property2: 'system.subsystem2.property2',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const expected = [
|
||||
{
|
||||
name: 'db',
|
||||
status: 'UP',
|
||||
details: {
|
||||
database: 'H2',
|
||||
hello: '1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mail',
|
||||
error: 'mail.a.b.c',
|
||||
status: 'UP',
|
||||
},
|
||||
{
|
||||
name: 'system',
|
||||
error: 'show me',
|
||||
status: 'DOWN',
|
||||
},
|
||||
{
|
||||
name: 'system.subsystem1',
|
||||
status: 'UP',
|
||||
details: {
|
||||
property1: 'system.subsystem1.property1',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'system.subsystem2',
|
||||
error: 'system.subsystem1.error',
|
||||
status: 'DOWN',
|
||||
details: {
|
||||
property2: 'system.subsystem2.property2',
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(healthService.transformHealthData(data)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
126
src/main/webapp/app/admin/health/health.service.ts
Normal file
126
src/main/webapp/app/admin/health/health.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import axios, { type AxiosPromise } from 'axios';
|
||||
|
||||
export default class HealthService {
|
||||
separator: string;
|
||||
|
||||
constructor() {
|
||||
this.separator = '.';
|
||||
}
|
||||
|
||||
checkHealth(): AxiosPromise<any> {
|
||||
return axios.get('management/health');
|
||||
}
|
||||
|
||||
transformHealthData(data: any): any {
|
||||
const response = [];
|
||||
this.flattenHealthData(response, null, data.components);
|
||||
return response;
|
||||
}
|
||||
|
||||
getBaseName(name: string): string {
|
||||
if (name) {
|
||||
const split = name.split('.');
|
||||
return split[0];
|
||||
}
|
||||
}
|
||||
|
||||
getSubSystemName(name: string): string {
|
||||
if (name) {
|
||||
const split = name.split('.');
|
||||
split.splice(0, 1);
|
||||
const remainder = split.join('.');
|
||||
return remainder ? ` - ${remainder}` : '';
|
||||
}
|
||||
}
|
||||
|
||||
addHealthObject(result: any, isLeaf: boolean, healthObject: any, name: string) {
|
||||
const healthData = {
|
||||
name,
|
||||
details: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
const details = {};
|
||||
let hasDetails = false;
|
||||
|
||||
for (const key in healthObject) {
|
||||
if (Object.hasOwn(healthObject, key)) {
|
||||
const value = healthObject[key];
|
||||
if (key === 'status' || key === 'error') {
|
||||
healthData[key] = value;
|
||||
} else {
|
||||
if (!this.isHealthObject(value)) {
|
||||
details[key] = value;
|
||||
hasDetails = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the details
|
||||
if (hasDetails) {
|
||||
healthData.details = details;
|
||||
}
|
||||
|
||||
// Only add nodes if they provide additional information
|
||||
if (isLeaf || hasDetails || healthData.error) {
|
||||
result.push(healthData);
|
||||
}
|
||||
return healthData;
|
||||
}
|
||||
|
||||
flattenHealthData(result: any, path: any, data: any): any {
|
||||
for (const key in data) {
|
||||
if (Object.hasOwn(data, key)) {
|
||||
const value = data[key];
|
||||
if (this.isHealthObject(value)) {
|
||||
if (this.hasSubSystem(value)) {
|
||||
this.addHealthObject(result, false, value, this.getModuleName(path, key));
|
||||
this.flattenHealthData(result, this.getModuleName(path, key), value);
|
||||
} else {
|
||||
this.addHealthObject(result, true, value, this.getModuleName(path, key));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
getModuleName(path: any, name: string) {
|
||||
if (path && name) {
|
||||
return path + this.separator + name;
|
||||
} else if (path) {
|
||||
return path;
|
||||
} else if (name) {
|
||||
return name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
hasSubSystem(healthObject: any): any {
|
||||
let result = false;
|
||||
|
||||
for (const key in healthObject) {
|
||||
if (Object.hasOwn(healthObject, key)) {
|
||||
const value = healthObject[key];
|
||||
if (value?.status) {
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
isHealthObject(healthObject: any): any {
|
||||
let result = false;
|
||||
|
||||
for (const key in healthObject) {
|
||||
if (Object.hasOwn(healthObject, key)) {
|
||||
if (key === 'status') {
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
45
src/main/webapp/app/admin/health/health.vue
Normal file
45
src/main/webapp/app/admin/health/health.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
<span id="health-page-heading" data-cy="healthPageHeading">{{ t$('health.title') }}</span>
|
||||
<button class="btn btn-primary float-end" @click="refresh()" :disabled="updatingHealth">
|
||||
<font-awesome-icon icon="sync"></font-awesome-icon> <span>{{ t$("health['refresh.button']") }}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div class="table-responsive">
|
||||
<table id="healthCheck" class="table table-striped" aria-describedby="Health check">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ t$('health.table.service') }}</th>
|
||||
<th class="text-center" scope="col">{{ t$('health.table.status') }}</th>
|
||||
<th class="text-center" scope="col">{{ t$('health.details.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="health of healthData" :key="health.name">
|
||||
<td><span />{{ t$('health.indicator.' + baseName(health.name)) }} {{ subSystemName(health.name) }}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge" :class="getBadgeClass(health.status)">{{ t$('health.status.' + health.status) }}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<a class="hand" @click="showHealth(health)" v-if="health.details || health.error">
|
||||
<font-awesome-icon icon="eye"></font-awesome-icon>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<b-modal ref="healthModal" ok-only>
|
||||
<template #title>
|
||||
<h4 v-if="currentHealth" class="modal-title" id="showHealthLabel">
|
||||
<span>{{ t$('health.indicator.' + baseName(currentHealth.name)) }}</span>
|
||||
{{ subSystemName(currentHealth.name) }}
|
||||
</h4>
|
||||
</template>
|
||||
<health-modal :current-health="currentHealth"></health-modal>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./health.component.ts"></script>
|
||||
72
src/main/webapp/app/admin/logs/logs.component.spec.ts
Normal file
72
src/main/webapp/app/admin/logs/logs.component.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import Logs from './logs.vue';
|
||||
|
||||
type LogsComponentType = InstanceType<typeof Logs>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
};
|
||||
|
||||
describe('Logs Component', () => {
|
||||
let logs: LogsComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.resolves({});
|
||||
const wrapper = shallowMount(Logs, {
|
||||
global: {
|
||||
stubs: {
|
||||
'jhi-sort-indicator': true,
|
||||
BButton: true,
|
||||
BButtonGroup: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
logs = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('OnInit', () => {
|
||||
it('should set all default values correctly', () => {
|
||||
expect(logs.filtered).toBe('');
|
||||
expect(logs.orderProp).toBe('name');
|
||||
expect(logs.reverse).toBe(false);
|
||||
});
|
||||
|
||||
it('Should call load all on init', async () => {
|
||||
// WHEN
|
||||
logs.init();
|
||||
await logs.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('management/loggers')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('change log level', () => {
|
||||
it('should change log level correctly', async () => {
|
||||
axiosStub.post.resolves({});
|
||||
|
||||
// WHEN
|
||||
logs.updateLevel('main', 'ERROR');
|
||||
await logs.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.post.calledWith('management/loggers/main', { configuredLevel: 'ERROR' })).toBeTruthy();
|
||||
expect(axiosStub.get.calledWith('management/loggers')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('change order', () => {
|
||||
it('should change order and invert reverse', () => {
|
||||
// WHEN
|
||||
logs.changeOrder('dummy-order');
|
||||
|
||||
// THEN
|
||||
expect(logs.orderProp).toEqual('dummy-order');
|
||||
expect(logs.reverse).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
src/main/webapp/app/admin/logs/logs.component.ts
Normal file
63
src/main/webapp/app/admin/logs/logs.component.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { orderAndFilterBy } from '@/shared/computables';
|
||||
|
||||
import LogsService from './logs.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiLogs',
|
||||
setup() {
|
||||
const logsService = inject('logsService', () => new LogsService(), true);
|
||||
|
||||
const loggers: Ref<any[]> = ref([]);
|
||||
const filtered = ref('');
|
||||
const orderProp = ref('name');
|
||||
const reverse = ref(false);
|
||||
const filteredLoggers = computed(() =>
|
||||
orderAndFilterBy(loggers.value, {
|
||||
filterByTerm: filtered.value,
|
||||
orderByProp: orderProp.value,
|
||||
reverse: reverse.value,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
logsService,
|
||||
loggers,
|
||||
filtered,
|
||||
orderProp,
|
||||
reverse,
|
||||
filteredLoggers,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.init();
|
||||
},
|
||||
methods: {
|
||||
init(): void {
|
||||
this.logsService.findAll().then(response => {
|
||||
this.extractLoggers(response);
|
||||
});
|
||||
},
|
||||
updateLevel(name: string, level: string): void {
|
||||
this.logsService.changeLevel(name, level).then(() => {
|
||||
this.init();
|
||||
});
|
||||
},
|
||||
changeOrder(orderProp: string): void {
|
||||
this.orderProp = orderProp;
|
||||
this.reverse = !this.reverse;
|
||||
},
|
||||
extractLoggers(response) {
|
||||
this.loggers = [];
|
||||
if (response.data) {
|
||||
for (const key of Object.keys(response.data.loggers)) {
|
||||
const logger = response.data.loggers[key];
|
||||
this.loggers.push({ name: key, level: logger.effectiveLevel });
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
11
src/main/webapp/app/admin/logs/logs.service.ts
Normal file
11
src/main/webapp/app/admin/logs/logs.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import axios, { type AxiosPromise } from 'axios';
|
||||
|
||||
export default class LogsService {
|
||||
changeLevel(name: string, configuredLevel: string): AxiosPromise<any> {
|
||||
return axios.post(`management/loggers/${name}`, { configuredLevel });
|
||||
}
|
||||
|
||||
findAll(): AxiosPromise<any> {
|
||||
return axios.get('management/loggers');
|
||||
}
|
||||
}
|
||||
56
src/main/webapp/app/admin/logs/logs.vue
Normal file
56
src/main/webapp/app/admin/logs/logs.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="table-responsive">
|
||||
<h2 id="logs-page-heading" data-cy="logsPageHeading">{{ t$('logs.title') }}</h2>
|
||||
|
||||
<div v-if="loggers">
|
||||
<p>{{ t$('logs.nbloggers', { total: loggers.length }) }}</p>
|
||||
|
||||
<span>{{ t$('logs.filter') }}</span> <input type="text" v-model="filtered" class="form-control" />
|
||||
|
||||
<table class="table table-sm table-striped table-bordered" aria-describedby="Logs">
|
||||
<thead>
|
||||
<tr title="click to order">
|
||||
<th @click="changeOrder('name')" scope="col">
|
||||
<span>{{ t$('logs.table.name') }}</span>
|
||||
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'name'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th @click="changeOrder('level')" scope="col">
|
||||
<span>{{ t$('logs.table.level') }}</span>
|
||||
<jhi-sort-indicator :current-order="orderProp" :reverse="reverse" :field-name="'level'"></jhi-sort-indicator>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tr v-for="logger in filteredLoggers" :key="logger.name">
|
||||
<td>
|
||||
<small>{{ logger.name }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<BButtonGroup role="group" aria-label="Log level" size="sm" class="flex-nowrap">
|
||||
<BButton @click="updateLevel(logger.name, 'TRACE')" :variant="logger.level === 'TRACE' ? 'primary' : 'light'" size="sm">
|
||||
TRACE
|
||||
</BButton>
|
||||
<BButton @click="updateLevel(logger.name, 'DEBUG')" :variant="logger.level === 'DEBUG' ? 'success' : 'light'" size="sm">
|
||||
DEBUG
|
||||
</BButton>
|
||||
<BButton @click="updateLevel(logger.name, 'INFO')" :variant="logger.level === 'INFO' ? 'info' : 'light'" size="sm">
|
||||
INFO
|
||||
</BButton>
|
||||
<BButton @click="updateLevel(logger.name, 'WARN')" :variant="logger.level === 'WARN' ? 'warning' : 'light'" size="sm">
|
||||
WARN
|
||||
</BButton>
|
||||
<BButton @click="updateLevel(logger.name, 'ERROR')" :variant="logger.level === 'ERROR' ? 'danger' : 'light'" size="sm">
|
||||
ERROR
|
||||
</BButton>
|
||||
<BButton @click="updateLevel(logger.name, 'OFF')" :variant="logger.level === 'OFF' ? 'secondary' : 'light'" size="sm">
|
||||
OFF
|
||||
</BButton>
|
||||
</BButtonGroup>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./logs.component.ts"></script>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import MetricsModal from './metrics-modal.vue';
|
||||
|
||||
type MetricsModalComponentType = InstanceType<typeof MetricsModal>;
|
||||
|
||||
describe('Metrics Component', () => {
|
||||
let metricsModal: MetricsModalComponentType;
|
||||
|
||||
beforeEach(() => {
|
||||
const wrapper = shallowMount(MetricsModal, {
|
||||
propsData: {
|
||||
threadDump: [
|
||||
{ name: 'test1', threadState: 'RUNNABLE' },
|
||||
{ name: 'test2', threadState: 'WAITING' },
|
||||
{ name: 'test3', threadState: 'TIMED_WAITING' },
|
||||
{ name: 'test4', threadState: 'BLOCKED' },
|
||||
{ name: 'test5', threadState: 'BLOCKED' },
|
||||
{ name: 'test5', threadState: 'NONE' },
|
||||
],
|
||||
},
|
||||
});
|
||||
metricsModal = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should count the numbers of each thread type', () => {
|
||||
expect(metricsModal.threadDumpData.threadDumpRunnable).toBe(1);
|
||||
expect(metricsModal.threadDumpData.threadDumpWaiting).toBe(1);
|
||||
expect(metricsModal.threadDumpData.threadDumpTimedWaiting).toBe(1);
|
||||
expect(metricsModal.threadDumpData.threadDumpBlocked).toBe(2);
|
||||
expect(metricsModal.threadDumpData.threadDumpAll).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBadgeClass', () => {
|
||||
it('should return bg-success for RUNNABLE', () => {
|
||||
expect(metricsModal.getBadgeClass('RUNNABLE')).toBe('bg-success');
|
||||
});
|
||||
|
||||
it('should return bg-info for WAITING', () => {
|
||||
expect(metricsModal.getBadgeClass('WAITING')).toBe('bg-info');
|
||||
});
|
||||
|
||||
it('should return bg-warning for TIMED_WAITING', () => {
|
||||
expect(metricsModal.getBadgeClass('TIMED_WAITING')).toBe('bg-warning');
|
||||
});
|
||||
|
||||
it('should return bg-danger for BLOCKED', () => {
|
||||
expect(metricsModal.getBadgeClass('BLOCKED')).toBe('bg-danger');
|
||||
});
|
||||
|
||||
it('should return an empty string for anything else', () => {
|
||||
expect(metricsModal.getBadgeClass('')).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/main/webapp/app/admin/metrics/metrics-modal.component.ts
Normal file
64
src/main/webapp/app/admin/metrics/metrics-modal.component.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { type PropType, type Ref, computed, defineComponent, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { filterBy } from '@/shared/computables';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiMetricsModal',
|
||||
props: {
|
||||
threadDump: {
|
||||
type: Array as PropType<any[]>,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const threadDumpFilter: Ref<any> = ref('');
|
||||
const filteredThreadDump = computed(() => filterBy(props.threadDump, { filterByTerm: threadDumpFilter.value }));
|
||||
|
||||
const threadDumpData = computed(() => {
|
||||
const data = {
|
||||
threadDumpAll: 0,
|
||||
threadDumpBlocked: 0,
|
||||
threadDumpRunnable: 0,
|
||||
threadDumpTimedWaiting: 0,
|
||||
threadDumpWaiting: 0,
|
||||
};
|
||||
if (props.threadDump) {
|
||||
props.threadDump.forEach(value => {
|
||||
if (value.threadState === 'RUNNABLE') {
|
||||
data.threadDumpRunnable += 1;
|
||||
} else if (value.threadState === 'WAITING') {
|
||||
data.threadDumpWaiting += 1;
|
||||
} else if (value.threadState === 'TIMED_WAITING') {
|
||||
data.threadDumpTimedWaiting += 1;
|
||||
} else if (value.threadState === 'BLOCKED') {
|
||||
data.threadDumpBlocked += 1;
|
||||
}
|
||||
});
|
||||
data.threadDumpAll = data.threadDumpRunnable + data.threadDumpWaiting + data.threadDumpTimedWaiting + data.threadDumpBlocked;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
|
||||
return {
|
||||
threadDumpFilter,
|
||||
threadDumpData,
|
||||
filteredThreadDump,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
getBadgeClass(threadState: string): string {
|
||||
if (threadState === 'RUNNABLE') {
|
||||
return 'bg-success';
|
||||
} else if (threadState === 'WAITING') {
|
||||
return 'bg-info';
|
||||
} else if (threadState === 'TIMED_WAITING') {
|
||||
return 'bg-warning';
|
||||
} else if (threadState === 'BLOCKED') {
|
||||
return 'bg-danger';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
});
|
||||
67
src/main/webapp/app/admin/metrics/metrics-modal.vue
Normal file
67
src/main/webapp/app/admin/metrics/metrics-modal.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="modal-body">
|
||||
<span class="badge bg-primary" @click="threadDumpFilter = ''"
|
||||
>All <span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpAll }}</span></span
|
||||
>
|
||||
<span class="badge bg-success" @click="threadDumpFilter = 'RUNNABLE'"
|
||||
>Runnable <span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpRunnable }}</span></span
|
||||
>
|
||||
<span class="badge bg-info" @click="threadDumpFilter = 'WAITING'"
|
||||
>Waiting <span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpWaiting }}</span></span
|
||||
>
|
||||
<span class="badge bg-warning" @click="threadDumpFilter = 'TIMED_WAITING'"
|
||||
>Timed Waiting <span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpTimedWaiting }}</span></span
|
||||
>
|
||||
<span class="badge bg-danger" @click="threadDumpFilter = 'BLOCKED'"
|
||||
>Blocked <span class="badge rounded-pill bg-default">{{ threadDumpData.threadDumpBlocked }}</span></span
|
||||
>
|
||||
<div class="mt-2"> </div>
|
||||
Filter
|
||||
<input type="text" v-model="threadDumpFilter" class="form-control" />
|
||||
<div class="pad" v-for="(entry, key1) of filteredThreadDump" :key="key1">
|
||||
<h6>
|
||||
<span class="badge" :class="getBadgeClass(entry.threadState)">{{ entry.threadState }}</span
|
||||
> {{ entry.threadName }} (ID {{ entry.threadId }})
|
||||
<a @click="entry.show = !entry.show" href="javascript:void(0);">
|
||||
<span :hidden="entry.show">{{ t$('metrics.jvm.threads.dump.show') }}</span>
|
||||
<span :hidden="!entry.show">{{ t$('metrics.jvm.threads.dump.hide') }}</span>
|
||||
</a>
|
||||
</h6>
|
||||
<div class="card" :hidden="!entry.show">
|
||||
<div class="card-body">
|
||||
<div v-for="(st, key2) of entry.stackTrace" :key="key2" class="break">
|
||||
<samp
|
||||
>{{ st.className }}.{{ st.methodName }}(<code>{{ st.fileName }}:{{ st.lineNumber }}</code
|
||||
>)</samp
|
||||
>
|
||||
<span class="mt-1"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-sm table-responsive" aria-describedby="Metrics">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ t$('metrics.jvm.threads.dump.blockedtime') }}</th>
|
||||
<th scope="col">{{ t$('metrics.jvm.threads.dump.blockedcount') }}</th>
|
||||
<th scope="col">{{ t$('metrics.jvm.threads.dump.waitedtime') }}</th>
|
||||
<th scope="col">{{ t$('metrics.jvm.threads.dump.waitedcount') }}</th>
|
||||
<th scope="col">{{ t$('metrics.jvm.threads.dump.lockname') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ entry.blockedTime }}</td>
|
||||
<td>{{ entry.blockedCount }}</td>
|
||||
<td>{{ entry.waitedTime }}</td>
|
||||
<td>{{ entry.waitedCount }}</td>
|
||||
<td class="thread-dump-modal-lock" :title="entry.lockName">
|
||||
<code>{{ entry.lockName }}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./metrics-modal.component.ts"></script>
|
||||
271
src/main/webapp/app/admin/metrics/metrics.component.spec.ts
Normal file
271
src/main/webapp/app/admin/metrics/metrics.component.spec.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import MetricsService from './metrics.service';
|
||||
import Metrics from './metrics.vue';
|
||||
|
||||
type MetricsComponentType = InstanceType<typeof Metrics>;
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
};
|
||||
|
||||
describe('Metrics Component', () => {
|
||||
let metricsComponent: MetricsComponentType;
|
||||
const response = {
|
||||
jvm: {
|
||||
'PS Eden Space': {
|
||||
committed: 5.57842432e8,
|
||||
max: 6.49592832e8,
|
||||
used: 4.20828184e8,
|
||||
},
|
||||
'Code Cache': {
|
||||
committed: 2.3461888e7,
|
||||
max: 2.5165824e8,
|
||||
used: 2.2594368e7,
|
||||
},
|
||||
'Compressed Class Space': {
|
||||
committed: 1.2320768e7,
|
||||
max: 1.073741824e9,
|
||||
used: 1.1514008e7,
|
||||
},
|
||||
'PS Survivor Space': {
|
||||
committed: 1.5204352e7,
|
||||
max: 1.5204352e7,
|
||||
used: 1.2244376e7,
|
||||
},
|
||||
'PS Old Gen': {
|
||||
committed: 1.10624768e8,
|
||||
max: 1.37887744e9,
|
||||
used: 4.1390776e7,
|
||||
},
|
||||
Metaspace: {
|
||||
committed: 9.170944e7,
|
||||
max: -1.0,
|
||||
used: 8.7377552e7,
|
||||
},
|
||||
},
|
||||
databases: {
|
||||
min: {
|
||||
value: 10.0,
|
||||
},
|
||||
max: {
|
||||
value: 10.0,
|
||||
},
|
||||
idle: {
|
||||
value: 10.0,
|
||||
},
|
||||
usage: {
|
||||
'0.0': 0.0,
|
||||
'1.0': 0.0,
|
||||
max: 0.0,
|
||||
totalTime: 4210.0,
|
||||
mean: 701.6666666666666,
|
||||
'0.5': 0.0,
|
||||
count: 6,
|
||||
'0.99': 0.0,
|
||||
'0.75': 0.0,
|
||||
'0.95': 0.0,
|
||||
},
|
||||
pending: {
|
||||
value: 0.0,
|
||||
},
|
||||
active: {
|
||||
value: 0.0,
|
||||
},
|
||||
acquire: {
|
||||
'0.0': 0.0,
|
||||
'1.0': 0.0,
|
||||
max: 0.0,
|
||||
totalTime: 0.884426,
|
||||
mean: 0.14740433333333333,
|
||||
'0.5': 0.0,
|
||||
count: 6,
|
||||
'0.99': 0.0,
|
||||
'0.75': 0.0,
|
||||
'0.95': 0.0,
|
||||
},
|
||||
creation: {
|
||||
'0.0': 0.0,
|
||||
'1.0': 0.0,
|
||||
max: 0.0,
|
||||
totalTime: 27.0,
|
||||
mean: 3.0,
|
||||
'0.5': 0.0,
|
||||
count: 9,
|
||||
'0.99': 0.0,
|
||||
'0.75': 0.0,
|
||||
'0.95': 0.0,
|
||||
},
|
||||
connections: {
|
||||
value: 10.0,
|
||||
},
|
||||
},
|
||||
'http.server.requests': {
|
||||
all: {
|
||||
count: 5,
|
||||
},
|
||||
percode: {
|
||||
'200': {
|
||||
max: 0.0,
|
||||
mean: 298.9012628,
|
||||
count: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
cache: {
|
||||
usersByEmail: {
|
||||
'cache.gets.miss': 0.0,
|
||||
'cache.puts': 0.0,
|
||||
'cache.gets.hit': 0.0,
|
||||
'cache.removals': 0.0,
|
||||
'cache.evictions': 0.0,
|
||||
},
|
||||
usersByLogin: {
|
||||
'cache.gets.miss': 1.0,
|
||||
'cache.puts': 1.0,
|
||||
'cache.gets.hit': 1.0,
|
||||
'cache.removals': 0.0,
|
||||
'cache.evictions': 0.0,
|
||||
},
|
||||
'tech.jhipster.domain.Authority': {
|
||||
'cache.gets.miss': 0.0,
|
||||
'cache.puts': 2.0,
|
||||
'cache.gets.hit': 0.0,
|
||||
'cache.removals': 0.0,
|
||||
'cache.evictions': 0.0,
|
||||
},
|
||||
'tech.jhipster.domain.User.authorities': {
|
||||
'cache.gets.miss': 0.0,
|
||||
'cache.puts': 1.0,
|
||||
'cache.gets.hit': 0.0,
|
||||
'cache.removals': 0.0,
|
||||
'cache.evictions': 0.0,
|
||||
},
|
||||
'tech.jhipster.domain.User': {
|
||||
'cache.gets.miss': 0.0,
|
||||
'cache.puts': 1.0,
|
||||
'cache.gets.hit': 0.0,
|
||||
'cache.removals': 0.0,
|
||||
'cache.evictions': 0.0,
|
||||
},
|
||||
},
|
||||
garbageCollector: {
|
||||
'jvm.gc.max.data.size': 1.37887744e9,
|
||||
'jvm.gc.pause': {
|
||||
'0.0': 0.0,
|
||||
'1.0': 0.0,
|
||||
max: 0.0,
|
||||
totalTime: 242.0,
|
||||
mean: 242.0,
|
||||
'0.5': 0.0,
|
||||
count: 1,
|
||||
'0.99': 0.0,
|
||||
'0.75': 0.0,
|
||||
'0.95': 0.0,
|
||||
},
|
||||
'jvm.gc.memory.promoted': 2.992732e7,
|
||||
'jvm.gc.memory.allocated': 1.26362872e9,
|
||||
classesLoaded: 17393.0,
|
||||
'jvm.gc.live.data.size': 3.1554408e7,
|
||||
classesUnloaded: 0.0,
|
||||
},
|
||||
services: {
|
||||
'/management/info': {
|
||||
GET: {
|
||||
max: 0.0,
|
||||
mean: 104.952893,
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
'/api/authenticate': {
|
||||
POST: {
|
||||
max: 0.0,
|
||||
mean: 909.53003,
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
'/api/account': {
|
||||
GET: {
|
||||
max: 0.0,
|
||||
mean: 141.209628,
|
||||
count: 1,
|
||||
},
|
||||
},
|
||||
'/**': {
|
||||
GET: {
|
||||
max: 0.0,
|
||||
mean: 169.4068815,
|
||||
count: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
processMetrics: {
|
||||
'system.load.average.1m': 3.63,
|
||||
'system.cpu.usage': 0.5724934148485453,
|
||||
'system.cpu.count': 4.0,
|
||||
'process.start.time': 1.548140811306e12,
|
||||
'process.files.open': 205.0,
|
||||
'process.cpu.usage': 0.003456347568026252,
|
||||
'process.uptime': 88404.0,
|
||||
'process.files.max': 1048576.0,
|
||||
},
|
||||
threads: [{ name: 'test1', threadState: 'RUNNABLE' }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.get.resolves({ data: { timers: [], gauges: [] } });
|
||||
const wrapper = shallowMount(Metrics, {
|
||||
global: {
|
||||
stubs: {
|
||||
bModal: true,
|
||||
bProgress: true,
|
||||
bProgressBar: true,
|
||||
'font-awesome-icon': true,
|
||||
'metrics-modal': true,
|
||||
},
|
||||
directives: {
|
||||
'b-modal': {},
|
||||
'b-progress': {},
|
||||
'b-progress-bar': {},
|
||||
},
|
||||
provide: {
|
||||
metricsService: new MetricsService(),
|
||||
},
|
||||
},
|
||||
});
|
||||
metricsComponent = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('should call refresh on init', async () => {
|
||||
// GIVEN
|
||||
axiosStub.get.resolves({ data: response });
|
||||
|
||||
// WHEN
|
||||
await metricsComponent.refresh();
|
||||
await metricsComponent.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('management/jhimetrics')).toBeTruthy();
|
||||
expect(axiosStub.get.calledWith('management/threaddump')).toBeTruthy();
|
||||
expect(metricsComponent.metrics).toHaveProperty('jvm');
|
||||
expect(metricsComponent.metrics).toEqual(response);
|
||||
expect(metricsComponent.threadStats).toEqual({
|
||||
threadDumpRunnable: 1,
|
||||
threadDumpWaiting: 0,
|
||||
threadDumpTimedWaiting: 0,
|
||||
threadDumpBlocked: 0,
|
||||
threadDumpAll: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNan', () => {
|
||||
it('should return if a variable is NaN', () => {
|
||||
expect(metricsComponent.filterNaN(1)).toBe(1);
|
||||
expect(metricsComponent.filterNaN('test')).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
134
src/main/webapp/app/admin/metrics/metrics.component.ts
Normal file
134
src/main/webapp/app/admin/metrics/metrics.component.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import numeral from 'numeral';
|
||||
|
||||
import { useDateFormat } from '@/shared/composables';
|
||||
|
||||
import JhiMetricsModal from './metrics-modal.vue';
|
||||
import MetricsService from './metrics.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiMetrics',
|
||||
components: {
|
||||
'metrics-modal': JhiMetricsModal,
|
||||
},
|
||||
setup() {
|
||||
const { formatDate } = useDateFormat();
|
||||
const metricsService = inject('metricsService', () => new MetricsService(), true);
|
||||
|
||||
const metrics: Ref<any> = ref({});
|
||||
const threadData: Ref<any> = ref(null);
|
||||
const threadStats: Ref<any> = ref({});
|
||||
const updatingMetrics = ref(true);
|
||||
|
||||
return {
|
||||
metricsService,
|
||||
metrics,
|
||||
threadData,
|
||||
threadStats,
|
||||
updatingMetrics,
|
||||
formatDate,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
mounted(): void {
|
||||
this.refresh();
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
return this.metricsService
|
||||
.getMetrics()
|
||||
.then(resultsMetrics => {
|
||||
this.metrics = resultsMetrics.data;
|
||||
this.metricsService
|
||||
.retrieveThreadDump()
|
||||
.then(res => {
|
||||
this.updatingMetrics = true;
|
||||
this.threadData = res.data.threads;
|
||||
|
||||
this.threadStats = {
|
||||
threadDumpRunnable: 0,
|
||||
threadDumpWaiting: 0,
|
||||
threadDumpTimedWaiting: 0,
|
||||
threadDumpBlocked: 0,
|
||||
threadDumpAll: 0,
|
||||
};
|
||||
|
||||
this.threadData.forEach(value => {
|
||||
if (value.threadState === 'RUNNABLE') {
|
||||
this.threadStats.threadDumpRunnable += 1;
|
||||
} else if (value.threadState === 'WAITING') {
|
||||
this.threadStats.threadDumpWaiting += 1;
|
||||
} else if (value.threadState === 'TIMED_WAITING') {
|
||||
this.threadStats.threadDumpTimedWaiting += 1;
|
||||
} else if (value.threadState === 'BLOCKED') {
|
||||
this.threadStats.threadDumpBlocked += 1;
|
||||
}
|
||||
});
|
||||
|
||||
this.threadStats.threadDumpAll =
|
||||
this.threadStats.threadDumpRunnable +
|
||||
this.threadStats.threadDumpWaiting +
|
||||
this.threadStats.threadDumpTimedWaiting +
|
||||
this.threadStats.threadDumpBlocked;
|
||||
|
||||
this.updatingMetrics = false;
|
||||
})
|
||||
.catch(() => {
|
||||
this.updatingMetrics = true;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this.updatingMetrics = true;
|
||||
});
|
||||
},
|
||||
openModal(): void {
|
||||
if ((<any>this.$refs.metricsModal).show) {
|
||||
(<any>this.$refs.metricsModal).show();
|
||||
}
|
||||
},
|
||||
filterNaN(input: any): any {
|
||||
if (isNaN(input)) {
|
||||
return 0;
|
||||
}
|
||||
return input;
|
||||
},
|
||||
formatNumber1(value: any): any {
|
||||
return numeral(value).format('0,0');
|
||||
},
|
||||
formatNumber2(value: any): any {
|
||||
return numeral(value).format('0,00');
|
||||
},
|
||||
convertMillisecondsToDuration(ms) {
|
||||
const times = {
|
||||
year: 31557600000,
|
||||
month: 2629746000,
|
||||
day: 86400000,
|
||||
hour: 3600000,
|
||||
minute: 60000,
|
||||
second: 1000,
|
||||
};
|
||||
let time_string = '';
|
||||
let plural = '';
|
||||
for (const key in times) {
|
||||
if (Math.floor(ms / times[key]) > 0) {
|
||||
if (Math.floor(ms / times[key]) > 1) {
|
||||
plural = 's';
|
||||
} else {
|
||||
plural = '';
|
||||
}
|
||||
time_string += `${Math.floor(ms / times[key])} ${key}${plural} `;
|
||||
ms = ms - times[key] * Math.floor(ms / times[key]);
|
||||
}
|
||||
}
|
||||
return time_string;
|
||||
},
|
||||
isObjectExisting(metrics: any, key: string): boolean {
|
||||
return metrics?.[key];
|
||||
},
|
||||
isObjectExistingAndNotEmpty(metrics: any, key: string): boolean {
|
||||
return this.isObjectExisting(metrics, key) && JSON.stringify(metrics[key]) !== '{}';
|
||||
},
|
||||
},
|
||||
});
|
||||
11
src/main/webapp/app/admin/metrics/metrics.service.ts
Normal file
11
src/main/webapp/app/admin/metrics/metrics.service.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import axios, { type AxiosPromise } from 'axios';
|
||||
|
||||
export default class MetricsService {
|
||||
getMetrics(): AxiosPromise<any> {
|
||||
return axios.get('management/jhimetrics');
|
||||
}
|
||||
|
||||
retrieveThreadDump(): AxiosPromise<any> {
|
||||
return axios.get('management/threaddump');
|
||||
}
|
||||
}
|
||||
371
src/main/webapp/app/admin/metrics/metrics.vue
Normal file
371
src/main/webapp/app/admin/metrics/metrics.vue
Normal file
@@ -0,0 +1,371 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
<span id="metrics-page-heading" data-cy="metricsPageHeading">{{ t$('metrics.title') }}</span>
|
||||
<button class="btn btn-primary float-end" @click="refresh()">
|
||||
<font-awesome-icon icon="sync"></font-awesome-icon> <span>{{ t$("metrics['refresh.button']") }}</span>
|
||||
</button>
|
||||
</h2>
|
||||
|
||||
<h3>{{ t$('metrics.jvm.title') }}</h3>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-4">
|
||||
<h4>{{ t$('metrics.jvm.memory.title') }}</h4>
|
||||
<div>
|
||||
<div v-for="(entry, key) of metrics.jvm" :key="key">
|
||||
<span v-if="entry.max !== -1">
|
||||
<span>{{ key }}</span> ({{ formatNumber1(entry.used / 1048576) }}M / {{ formatNumber1(entry.max / 1048576) }}M)
|
||||
</span>
|
||||
<span v-else>
|
||||
<span>{{ key }}</span> {{ formatNumber1(entry.used / 1048576) }}M
|
||||
</span>
|
||||
<div>Committed : {{ formatNumber1(entry.committed / 1048576) }}M</div>
|
||||
<b-progress v-if="entry.max !== -1" variant="success" animated :max="entry.max" striped>
|
||||
<b-progress-bar :value="entry.used" :label="formatNumber1((entry.used * 100) / entry.max) + '%'"> </b-progress-bar>
|
||||
</b-progress>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h4>{{ t$('metrics.jvm.threads.title') }}</h4>
|
||||
<span
|
||||
><span>{{ t$('metrics.jvm.threads.runnable') }}</span> {{ threadStats.threadDumpRunnable }}</span
|
||||
>
|
||||
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
|
||||
<b-progress-bar
|
||||
:value="threadStats.threadDumpRunnable"
|
||||
:label="formatNumber1((threadStats.threadDumpRunnable * 100) / threadStats.threadDumpAll) + '%'"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
|
||||
<span
|
||||
><span>{{ t$('metrics.jvm.threads.timedwaiting') }}</span> ({{ threadStats.threadDumpTimedWaiting }})</span
|
||||
>
|
||||
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
|
||||
<b-progress-bar
|
||||
:value="threadStats.threadDumpTimedWaiting"
|
||||
:label="formatNumber1((threadStats.threadDumpTimedWaiting * 100) / threadStats.threadDumpAll) + '%'"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
|
||||
<span
|
||||
><span>{{ t$('metrics.jvm.threads.waiting') }}</span> ({{ threadStats.threadDumpWaiting }})</span
|
||||
>
|
||||
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
|
||||
<b-progress-bar
|
||||
:value="threadStats.threadDumpWaiting"
|
||||
:label="formatNumber1((threadStats.threadDumpWaiting * 100) / threadStats.threadDumpAll) + '%'"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
|
||||
<span
|
||||
><span>{{ t$('metrics.jvm.threads.blocked') }}</span> ({{ threadStats.threadDumpBlocked }})</span
|
||||
>
|
||||
<b-progress variant="success" :max="threadStats.threadDumpAll" striped>
|
||||
<b-progress-bar
|
||||
:value="threadStats.threadDumpBlocked"
|
||||
:label="formatNumber1((threadStats.threadDumpBlocked * 100) / threadStats.threadDumpAll) + '%'"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
|
||||
<span
|
||||
>Total: {{ threadStats.threadDumpAll }}
|
||||
<a class="hand" v-b-modal.metricsModal data-toggle="modal" @click="openModal()" data-target="#threadDump">
|
||||
<font-awesome-icon icon="eye"></font-awesome-icon>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h4>System</h4>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-4">Uptime</div>
|
||||
<div class="col-md-8 text-end">{{ convertMillisecondsToDuration(metrics.processMetrics['process.uptime']) }}</div>
|
||||
</div>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-4">Start time</div>
|
||||
<div class="col-md-8 text-end">{{ formatDate(metrics.processMetrics['process.start.time']) }}</div>
|
||||
</div>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-9">Process CPU usage</div>
|
||||
<div class="col-md-3 text-end">{{ formatNumber2(100 * metrics.processMetrics['process.cpu.usage']) }} %</div>
|
||||
</div>
|
||||
<b-progress variant="success" :max="100" striped>
|
||||
<b-progress-bar
|
||||
:value="100 * metrics.processMetrics['process.cpu.usage']"
|
||||
:label="formatNumber1(100 * metrics.processMetrics['process.cpu.usage']) + '%'"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-9">System CPU usage</div>
|
||||
<div class="col-md-3 text-end">{{ formatNumber2(100 * metrics.processMetrics['system.cpu.usage']) }} %</div>
|
||||
</div>
|
||||
<b-progress variant="success" :max="100" striped>
|
||||
<b-progress-bar
|
||||
:value="100 * metrics.processMetrics['system.cpu.usage']"
|
||||
:label="formatNumber1(100 * metrics.processMetrics['system.cpu.usage']) + '%'"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-9">System CPU count</div>
|
||||
<div class="col-md-3 text-end">{{ metrics.processMetrics['system.cpu.count'] }}</div>
|
||||
</div>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-9">System 1m Load average</div>
|
||||
<div class="col-md-3 text-end">{{ formatNumber2(metrics.processMetrics['system.load.average.1m']) }}</div>
|
||||
</div>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-9">Process files max</div>
|
||||
<div class="col-md-3 text-end">{{ formatNumber1(metrics.processMetrics['process.files.max']) }}</div>
|
||||
</div>
|
||||
<div class="row" v-if="!updatingMetrics">
|
||||
<div class="col-md-9">Process files open</div>
|
||||
<div class="col-md-3 text-end">{{ formatNumber1(metrics.processMetrics['process.files.open']) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{{ t$('metrics.jvm.gc.title') }}</h3>
|
||||
<div class="row" v-if="!updatingMetrics && isObjectExisting(metrics, 'garbageCollector')">
|
||||
<div class="col-md-4">
|
||||
<div>
|
||||
<span>
|
||||
GC Live Data Size/GC Max Data Size ({{ formatNumber1(metrics.garbageCollector['jvm.gc.live.data.size'] / 1048576) }}M /
|
||||
{{ formatNumber1(metrics.garbageCollector['jvm.gc.max.data.size'] / 1048576) }}M)
|
||||
</span>
|
||||
<b-progress variant="success" :max="metrics.garbageCollector['jvm.gc.max.data.size']" striped>
|
||||
<b-progress-bar
|
||||
:value="metrics.garbageCollector['jvm.gc.live.data.size']"
|
||||
:label="
|
||||
formatNumber2(
|
||||
(100 * metrics.garbageCollector['jvm.gc.live.data.size']) / metrics.garbageCollector['jvm.gc.max.data.size'],
|
||||
) + '%'
|
||||
"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div>
|
||||
<span>
|
||||
GC Memory Promoted/GC Memory Allocated ({{ formatNumber1(metrics.garbageCollector['jvm.gc.memory.promoted'] / 1048576) }}M /
|
||||
{{ formatNumber1(metrics.garbageCollector['jvm.gc.memory.allocated'] / 1048576) }}M)
|
||||
</span>
|
||||
<b-progress variant="success" :max="metrics.garbageCollector['jvm.gc.memory.allocated']" striped>
|
||||
<b-progress-bar
|
||||
:value="metrics.garbageCollector['jvm.gc.memory.promoted']"
|
||||
:label="
|
||||
formatNumber2(
|
||||
(100 * metrics.garbageCollector['jvm.gc.memory.promoted']) / metrics.garbageCollector['jvm.gc.memory.allocated'],
|
||||
) + '%'
|
||||
"
|
||||
>
|
||||
</b-progress-bar>
|
||||
</b-progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="row">
|
||||
<div class="col-md-9">Classes loaded</div>
|
||||
<div class="col-md-3 text-end">{{ metrics.garbageCollector.classesLoaded }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-9">Classes unloaded</div>
|
||||
<div class="col-md-3 text-end">{{ metrics.garbageCollector.classesUnloaded }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" aria-describedby="Jvm gc">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col"></th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.count') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.mean') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.min') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p50') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p75') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p95') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p99') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.max') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>jvm.gc.pause</td>
|
||||
<td class="text-end">{{ metrics.garbageCollector['jvm.gc.pause'].count }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause'].mean) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.0']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.5']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.75']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.95']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause']['0.99']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.garbageCollector['jvm.gc.pause'].max) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>{{ t$('metrics.jvm.http.title') }}</h3>
|
||||
<table
|
||||
class="table table-striped"
|
||||
v-if="!updatingMetrics && isObjectExisting(metrics, 'http.server.requests')"
|
||||
aria-describedby="Jvm http"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ t$('metrics.jvm.http.table.code') }}</th>
|
||||
<th scope="col">{{ t$('metrics.jvm.http.table.count') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.jvm.http.table.mean') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.jvm.http.table.max') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, key) of metrics['http.server.requests']['percode']" :key="key">
|
||||
<td>{{ key }}</td>
|
||||
<td>
|
||||
<b-progress variant="success" animated :max="metrics['http.server.requests']['all'].count" striped>
|
||||
<b-progress-bar :value="entry.count" :label="formatNumber1(entry.count)"></b-progress-bar>
|
||||
</b-progress>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{{ formatNumber2(filterNaN(entry.mean)) }}
|
||||
</td>
|
||||
<td class="text-end">{{ formatNumber2(entry.max) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Endpoints requests (time in millisecond)</h3>
|
||||
<div class="table-responsive" v-if="!updatingMetrics">
|
||||
<table class="table table-striped" aria-describedby="Endpoint">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Method</th>
|
||||
<th scope="col">Endpoint url</th>
|
||||
<th scope="col" class="text-end">Count</th>
|
||||
<th scope="col" class="text-end">Mean</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(entry, entryKey) of metrics.services">
|
||||
<tr v-for="(method, methodKey) of entry" :key="entryKey + '-' + methodKey">
|
||||
<td>{{ methodKey }}</td>
|
||||
<td>{{ entryKey }}</td>
|
||||
<td class="text-end">{{ method.count }}</td>
|
||||
<td class="text-end">{{ formatNumber2(method.mean) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>{{ t$('metrics.cache.title') }}</h3>
|
||||
<div class="table-responsive" v-if="!updatingMetrics && isObjectExisting(metrics, 'cache')">
|
||||
<table class="table table-striped" aria-describedby="Cache">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{{ t$('metrics.cache.cachename') }}</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.hits">Cache Hits</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.misses">Cache Misses</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.gets">Cache Gets</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.puts">Cache Puts</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.removals">Cache Removals</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.evictions">Cache Evictions</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.hitPercent">Cache Hit %</th>
|
||||
<th scope="col" class="text-end" data-translate="metrics.cache.missPercent">Cache Miss %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(entry, key) of metrics.cache" :key="key">
|
||||
<td>{{ key }}</td>
|
||||
<td class="text-end">{{ entry['cache.gets.hit'] }}</td>
|
||||
<td class="text-end">{{ entry['cache.gets.miss'] }}</td>
|
||||
<td class="text-end">{{ entry['cache.gets.hit'] + entry['cache.gets.miss'] }}</td>
|
||||
<td class="text-end">{{ entry['cache.puts'] }}</td>
|
||||
<td class="text-end">{{ entry['cache.removals'] }}</td>
|
||||
<td class="text-end">{{ entry['cache.evictions'] }}</td>
|
||||
<td class="text-end">
|
||||
{{ formatNumber2(filterNaN((100 * entry['cache.gets.hit']) / (entry['cache.gets.hit'] + entry['cache.gets.miss']))) }}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{{ formatNumber2(filterNaN((100 * entry['cache.gets.miss']) / (entry['cache.gets.hit'] + entry['cache.gets.miss']))) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3>{{ t$('metrics.datasource.title') }}</h3>
|
||||
<div class="table-responsive" v-if="!updatingMetrics && isObjectExistingAndNotEmpty(metrics, 'databases')">
|
||||
<table class="table table-striped" aria-describedby="Connection pool">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">
|
||||
<span>{{ t$('metrics.datasource.usage') }}</span> (active: {{ metrics.databases.active.value }}, min:
|
||||
{{ metrics.databases.min.value }}, max: {{ metrics.databases.max.value }}, idle: {{ metrics.databases.idle.value }})
|
||||
</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.datasource.count') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.datasource.mean') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.min') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p50') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p75') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p95') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.servicesstats.table.p99') }}</th>
|
||||
<th scope="col" class="text-end">{{ t$('metrics.datasource.max') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Acquire</td>
|
||||
<td class="text-end">{{ metrics.databases.acquire.count }}</td>
|
||||
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.acquire.mean)) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.0']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.5']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.75']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.95']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.acquire['0.99']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.acquire.max)) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Creation</td>
|
||||
<td class="text-end">{{ metrics.databases.creation.count }}</td>
|
||||
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.creation.mean)) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.0']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.5']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.75']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.95']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.creation['0.99']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.creation.max)) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Usage</td>
|
||||
<td class="text-end">{{ metrics.databases.usage.count }}</td>
|
||||
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.usage.mean)) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.0']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.5']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.75']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.95']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(metrics.databases.usage['0.99']) }}</td>
|
||||
<td class="text-end">{{ formatNumber2(filterNaN(metrics.databases.usage.max)) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<b-modal ref="metricsModal" size="lg" ok-only>
|
||||
<template #title>
|
||||
<h4 class="modal-title" id="showMetricsLabel">{{ t$('metrics.jvm.threads.dump.title') }}</h4>
|
||||
</template>
|
||||
<metrics-modal :thread-dump="threadData"></metrics-modal>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./metrics.component.ts"></script>
|
||||
@@ -0,0 +1,155 @@
|
||||
import { vitest } from 'vitest';
|
||||
import { type RouteLocation } from 'vue-router';
|
||||
|
||||
import { type MountingOptions, shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import AlertService from '@/shared/alert/alert.service';
|
||||
|
||||
import UserManagementEdit from './user-management-edit.vue';
|
||||
|
||||
type UserManagementEditComponentType = InstanceType<typeof UserManagementEdit>;
|
||||
|
||||
let route: Partial<RouteLocation>;
|
||||
const routerGoMock = vitest.fn();
|
||||
|
||||
vitest.mock('vue-router', () => ({
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({ go: routerGoMock }),
|
||||
}));
|
||||
|
||||
describe('UserManagementEdit Component', () => {
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
post: sinon.stub(axios, 'post'),
|
||||
put: sinon.stub(axios, 'put'),
|
||||
};
|
||||
let mountOptions: MountingOptions<UserManagementEditComponentType>['global'];
|
||||
let alertService: AlertService;
|
||||
|
||||
beforeEach(() => {
|
||||
route = {};
|
||||
alertService = new AlertService({
|
||||
i18n: { t: vitest.fn() } as any,
|
||||
toast: {
|
||||
show: vitest.fn(),
|
||||
} as any,
|
||||
});
|
||||
|
||||
mountOptions = {
|
||||
stubs: {
|
||||
'font-awesome-icon': true,
|
||||
},
|
||||
provide: {
|
||||
alertService,
|
||||
},
|
||||
};
|
||||
|
||||
axiosStub.get.reset();
|
||||
axiosStub.post.reset();
|
||||
axiosStub.put.reset();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('Should load user', async () => {
|
||||
// GIVEN
|
||||
axiosStub.get.withArgs(`api/admin/users/${123}`).resolves({});
|
||||
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
|
||||
route = {
|
||||
params: {
|
||||
userId: `${123}`,
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
|
||||
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
|
||||
|
||||
// WHEN
|
||||
await userManagementEdit.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('api/authorities')).toBeTruthy();
|
||||
expect(axiosStub.get.calledWith(`api/admin/users/${123}`)).toBeTruthy();
|
||||
});
|
||||
it('Should open create user', async () => {
|
||||
// GIVEN
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
|
||||
route = {
|
||||
params: {},
|
||||
};
|
||||
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
|
||||
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
|
||||
|
||||
// WHEN
|
||||
await userManagementEdit.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith('api/authorities')).toBeTruthy();
|
||||
expect(axiosStub.get.callCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('Should call update service on save for existing user', async () => {
|
||||
// GIVEN
|
||||
axiosStub.put.resolves({
|
||||
headers: {
|
||||
'x-smartbookingapp-alert': '',
|
||||
'x-smartbookingapp-params': '',
|
||||
},
|
||||
});
|
||||
axiosStub.get.withArgs(`api/admin/users/${123}`).resolves({
|
||||
data: { id: 123, authorities: [] },
|
||||
});
|
||||
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
|
||||
route = {
|
||||
params: {
|
||||
userId: `${123}`,
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
|
||||
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
|
||||
await userManagementEdit.$nextTick();
|
||||
|
||||
// WHEN
|
||||
userManagementEdit.save();
|
||||
await userManagementEdit.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.put.calledWith('api/admin/users', { id: 123, authorities: [] })).toBeTruthy();
|
||||
expect(userManagementEdit.isSaving).toEqual(false);
|
||||
});
|
||||
|
||||
it('Should call create service on save for new user', async () => {
|
||||
// GIVEN
|
||||
axiosStub.post.resolves({
|
||||
headers: {
|
||||
'x-smartbookingapp-alert': '',
|
||||
'x-smartbookingapp-params': '',
|
||||
},
|
||||
});
|
||||
axiosStub.get.resolves({});
|
||||
axiosStub.get.withArgs('api/authorities').resolves({ data: [] });
|
||||
route = {
|
||||
params: {},
|
||||
};
|
||||
const wrapper = shallowMount(UserManagementEdit, { global: mountOptions });
|
||||
const userManagementEdit: UserManagementEditComponentType = wrapper.vm;
|
||||
await userManagementEdit.$nextTick();
|
||||
userManagementEdit.userAccount = { authorities: [] };
|
||||
|
||||
// WHEN
|
||||
userManagementEdit.save();
|
||||
await userManagementEdit.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(
|
||||
axiosStub.post.calledWith('api/admin/users', {
|
||||
authorities: [],
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(userManagementEdit.isSaving).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { email, maxLength, minLength, required } from '@vuelidate/validators';
|
||||
|
||||
import { useAlertService } from '@/shared/alert/alert.service';
|
||||
import languages from '@/shared/config/languages';
|
||||
import { type IUser, User } from '@/shared/model/user.model';
|
||||
|
||||
import UserManagementService from './user-management.service';
|
||||
|
||||
const loginValidator = (value: string) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return /^[a-zA-Z0-9!$&*+=?^_`{|}~.-]+@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$|^[_.@A-Za-z0-9-]+$/.test(value);
|
||||
};
|
||||
|
||||
const validations: any = {
|
||||
userAccount: {
|
||||
login: {
|
||||
required,
|
||||
maxLength: maxLength(254),
|
||||
pattern: loginValidator,
|
||||
},
|
||||
firstName: {
|
||||
maxLength: maxLength(50),
|
||||
},
|
||||
lastName: {
|
||||
maxLength: maxLength(50),
|
||||
},
|
||||
email: {
|
||||
required,
|
||||
email,
|
||||
minLength: minLength(5),
|
||||
maxLength: maxLength(50),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiUserManagementEdit',
|
||||
validations,
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const alertService = inject('alertService', () => useAlertService(), true);
|
||||
const userManagementService = inject('userManagementService', () => new UserManagementService(), true);
|
||||
const previousState = () => router.go(-1);
|
||||
|
||||
const userAccount: Ref<IUser> = ref({ ...new User(), authorities: [] });
|
||||
const isSaving: Ref<boolean> = ref(false);
|
||||
const authorities: Ref<string[]> = ref([]);
|
||||
|
||||
const initAuthorities = async () => {
|
||||
const response = await userManagementService.retrieveAuthorities();
|
||||
authorities.value = response.data;
|
||||
};
|
||||
|
||||
const loadUser = async (userId: string) => {
|
||||
const response = await userManagementService.get(userId);
|
||||
userAccount.value = response.data;
|
||||
};
|
||||
|
||||
initAuthorities();
|
||||
const userId = route.params?.userId;
|
||||
if (userId) {
|
||||
loadUser(userId);
|
||||
}
|
||||
|
||||
return {
|
||||
alertService,
|
||||
userAccount,
|
||||
isSaving,
|
||||
authorities,
|
||||
userManagementService,
|
||||
previousState,
|
||||
v$: useVuelidate(),
|
||||
languages: languages(),
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
save(): void {
|
||||
this.isSaving = true;
|
||||
if (this.userAccount.id) {
|
||||
this.userManagementService
|
||||
.update(this.userAccount)
|
||||
.then(res => {
|
||||
this.returnToList();
|
||||
this.alertService.showInfo(this.getToastMessageFromHeader(res));
|
||||
})
|
||||
.catch(error => {
|
||||
this.isSaving = true;
|
||||
this.alertService.showHttpError(error.response);
|
||||
});
|
||||
} else {
|
||||
this.userManagementService
|
||||
.create(this.userAccount)
|
||||
.then(res => {
|
||||
this.returnToList();
|
||||
this.alertService.showSuccess(this.getToastMessageFromHeader(res));
|
||||
})
|
||||
.catch(error => {
|
||||
this.isSaving = true;
|
||||
this.alertService.showHttpError(error.response);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
returnToList(): void {
|
||||
this.isSaving = false;
|
||||
this.previousState();
|
||||
},
|
||||
|
||||
getToastMessageFromHeader(res: any): string {
|
||||
return this.t$(res.headers['x-smartbookingapp-alert'], {
|
||||
param: decodeURIComponent(res.headers['x-smartbookingapp-params'].replace(/\+/g, ' ')),
|
||||
}).toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-8">
|
||||
<form name="editForm" novalidate @submit.prevent="save()" v-if="userAccount">
|
||||
<h2 id="myUserLabel">{{ t$('userManagement.home.createOrEditLabel') }}</h2>
|
||||
<div>
|
||||
<div class="mb-3" :hidden="!userAccount.id">
|
||||
<label>{{ t$('global.field.id') }}</label>
|
||||
<input type="text" class="form-control" name="id" v-model="userAccount.id" readonly />
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label">{{ t$('userManagement.login') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="login"
|
||||
:class="{ 'is-valid': !v$.userAccount.login.$invalid, 'is-invalid': v$.userAccount.login.$invalid }"
|
||||
v-model="v$.userAccount.login.$model"
|
||||
/>
|
||||
|
||||
<div v-if="v$.userAccount.login.$anyDirty && v$.userAccount.login.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.login.required.$invalid">{{
|
||||
t$('entity.validation.required')
|
||||
}}</small>
|
||||
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.login.maxLength.$invalid">{{
|
||||
t$('entity.validation.maxlength', { max: 50 })
|
||||
}}</small>
|
||||
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.login.pattern.$invalid">{{
|
||||
t$('entity.validation.patternLogin')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="firstName">{{ t$('userManagement.firstName') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
:placeholder="t$('settings.form[\'firstname.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.userAccount.firstName.$invalid, 'is-invalid': v$.userAccount.firstName.$invalid }"
|
||||
v-model="v$.userAccount.firstName.$model"
|
||||
/>
|
||||
<div v-if="v$.userAccount.firstName.$anyDirty && v$.userAccount.firstName.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.firstName.maxLength.$invalid">{{
|
||||
t$('entity.validation.maxlength', { max: 50 })
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="lastName">{{ t$('userManagement.lastName') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
:placeholder="t$('settings.form[\'lastname.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.userAccount.lastName.$invalid, 'is-invalid': v$.userAccount.lastName.$invalid }"
|
||||
v-model="v$.userAccount.lastName.$model"
|
||||
/>
|
||||
<div v-if="v$.userAccount.lastName.$anyDirty && v$.userAccount.lastName.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.lastName.maxLength.$invalid">{{
|
||||
t$('entity.validation.maxlength', { max: 50 })
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-control-label" for="email">{{ t$('userManagement.email') }}</label>
|
||||
<input
|
||||
type="email"
|
||||
class="form-control"
|
||||
id="email"
|
||||
name="email"
|
||||
:placeholder="t$('global.form[\'email.placeholder\']')"
|
||||
:class="{ 'is-valid': !v$.userAccount.email.$invalid, 'is-invalid': v$.userAccount.email.$invalid }"
|
||||
v-model="v$.userAccount.email.$model"
|
||||
email
|
||||
required
|
||||
/>
|
||||
<div v-if="v$.userAccount.email.$anyDirty && v$.userAccount.email.$invalid">
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.email.required.$invalid">{{
|
||||
t$('global.messages.validate.email.required')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.email.email.$invalid">{{
|
||||
t$('global.messages.validate.email.invalid')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.email.minLength.$invalid">{{
|
||||
t$('global.messages.validate.email.minlength')
|
||||
}}</small>
|
||||
<small class="form-text text-danger" v-if="v$.userAccount.email.maxLength.$invalid">{{
|
||||
t$('global.messages.validate.email.maxlength')
|
||||
}}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label class="form-check-label" for="activated">
|
||||
<input
|
||||
class="form-check-input"
|
||||
:disabled="userAccount.id === null"
|
||||
type="checkbox"
|
||||
id="activated"
|
||||
name="activated"
|
||||
v-model="userAccount.activated"
|
||||
/>
|
||||
<span>{{ t$('userManagement.activated') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" v-if="languages && Object.keys(languages).length > 0">
|
||||
<label for="langKey">{{ t$('userManagement.langKey') }}</label>
|
||||
<select class="form-control" id="langKey" name="langKey" v-model="userAccount.langKey">
|
||||
<option v-for="(language, key) in languages" :value="key" :key="key">{{ language.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>{{ t$('userManagement.profiles') }}</label>
|
||||
<select class="form-control" multiple name="authority" v-model="userAccount.authorities">
|
||||
<option v-for="authority of authorities" :value="authority" :key="authority">{{ authority }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary" @click="previousState()">
|
||||
<font-awesome-icon icon="ban"></font-awesome-icon> <span>{{ t$('entity.action.cancel') }}</span>
|
||||
</button>
|
||||
<button type="submit" :disabled="v$.userAccount.$invalid || isSaving" class="btn btn-primary">
|
||||
<font-awesome-icon icon="save"></font-awesome-icon> <span>{{ t$('entity.action.save') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./user-management-edit.component.ts"></script>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { vitest } from 'vitest';
|
||||
import { type RouteLocation } from 'vue-router';
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import AlertService from '@/shared/alert/alert.service';
|
||||
import { Authority } from '@/shared/security/authority';
|
||||
|
||||
import UserManagementView from './user-management-view.vue';
|
||||
|
||||
let route: Partial<RouteLocation>;
|
||||
|
||||
vitest.mock('vue-router', () => ({
|
||||
useRoute: () => route,
|
||||
}));
|
||||
|
||||
const axiosStub = {
|
||||
get: sinon.stub(axios, 'get'),
|
||||
};
|
||||
|
||||
describe('UserManagementView Component', () => {
|
||||
let alertService: AlertService;
|
||||
|
||||
beforeEach(() => {
|
||||
route = {};
|
||||
alertService = new AlertService({
|
||||
i18n: { t: vitest.fn() } as any,
|
||||
toast: {
|
||||
show: vitest.fn(),
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
|
||||
describe('OnInit', () => {
|
||||
it('Should call load all on init', async () => {
|
||||
// GIVEN
|
||||
const userData = {
|
||||
id: 1,
|
||||
login: 'user',
|
||||
firstName: 'first',
|
||||
lastName: 'last',
|
||||
email: 'first@last.com',
|
||||
activated: true,
|
||||
langKey: 'en',
|
||||
authorities: [Authority.USER],
|
||||
createdBy: 'admin',
|
||||
createdDate: null,
|
||||
lastModifiedBy: null,
|
||||
lastModifiedDate: null,
|
||||
password: null,
|
||||
};
|
||||
axiosStub.get.resolves({ data: userData });
|
||||
|
||||
route = {
|
||||
params: {
|
||||
userId: `${123}`,
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = shallowMount(UserManagementView, {
|
||||
global: {
|
||||
stubs: {
|
||||
'b-badge': true,
|
||||
'router-link': true,
|
||||
'font-awesome-icon': true,
|
||||
},
|
||||
provide: {
|
||||
alertService,
|
||||
},
|
||||
},
|
||||
});
|
||||
const userManagementView = wrapper.vm;
|
||||
|
||||
// WHEN
|
||||
await userManagementView.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith(`api/admin/users/${123}`)).toBeTruthy();
|
||||
expect(userManagementView.user).toEqual(userData);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useAlertService } from '@/shared/alert/alert.service';
|
||||
import { useDateFormat } from '@/shared/composables';
|
||||
|
||||
import UserManagementService from './user-management.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiUserManagementView',
|
||||
setup() {
|
||||
const route = useRoute();
|
||||
const { formatDateLong: formatDate } = useDateFormat();
|
||||
|
||||
const alertService = inject('alertService', () => useAlertService(), true);
|
||||
const userManagementService = inject('userManagementService', () => new UserManagementService(), true);
|
||||
|
||||
const user: Ref<any> = ref(null);
|
||||
|
||||
async function loadUser(userId: string) {
|
||||
try {
|
||||
const response = await userManagementService.get(userId);
|
||||
user.value = response.data;
|
||||
} catch (error) {
|
||||
alertService.showHttpError(error.response);
|
||||
}
|
||||
}
|
||||
|
||||
loadUser(route.params?.userId);
|
||||
|
||||
return {
|
||||
formatDate,
|
||||
alertService,
|
||||
userManagementService,
|
||||
user,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="col-8">
|
||||
<div v-if="user">
|
||||
<h2 class="jh-entity-heading">
|
||||
<span>{{ t$('userManagement.detail.title') }}</span> [<strong>{{ user.login }}</strong
|
||||
>]
|
||||
</h2>
|
||||
<dl class="row-md jh-entity-details">
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.login') }}</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<span>{{ user.login }}</span>
|
||||
<b-badge style="margin-left: 10px" :variant="user.activated ? 'success' : 'danger'">{{
|
||||
t$(user.activated ? 'userManagement.activated' : 'userManagement.deactivated')
|
||||
}}</b-badge>
|
||||
</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.firstName') }}</span>
|
||||
</dt>
|
||||
<dd>{{ user.firstName }}</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.lastName') }}</span>
|
||||
</dt>
|
||||
<dd>{{ user.lastName }}</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.email') }}</span>
|
||||
</dt>
|
||||
<dd>{{ user.email }}</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.langKey') }}</span>
|
||||
</dt>
|
||||
<dd>{{ user.langKey }}</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.createdBy') }}</span>
|
||||
</dt>
|
||||
<dd>{{ user.createdBy }}</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.createdDate') }}</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<span v-if="user.createdDate">
|
||||
{{ formatDate(user.createdDate) }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.lastModifiedBy') }}</span>
|
||||
</dt>
|
||||
<dd>{{ user.lastModifiedBy }}</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.lastModifiedDate') }}</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<span v-if="user.lastModifiedDate">
|
||||
{{ formatDate(user.lastModifiedDate) }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt>
|
||||
<span>{{ t$('userManagement.profiles') }}</span>
|
||||
</dt>
|
||||
<dd>
|
||||
<ul class="list-unstyled">
|
||||
<li v-for="authority of user.authorities" :key="authority">
|
||||
<b-badge variant="info">{{ authority }}</b-badge>
|
||||
</li>
|
||||
</ul>
|
||||
</dd>
|
||||
</dl>
|
||||
<router-link custom v-slot="{ navigate }" :to="{ name: 'JhiUser' }">
|
||||
<button @click="navigate" class="btn btn-info">
|
||||
<font-awesome-icon icon="arrow-left"></font-awesome-icon> <span>{{ t$('entity.action.back') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./user-management-view.component.ts"></script>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { vitest } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import axios from 'axios';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import AlertService from '@/shared/alert/alert.service';
|
||||
|
||||
import UserManagement from './user-management.vue';
|
||||
|
||||
type UserManagementComponentType = InstanceType<typeof UserManagement>;
|
||||
|
||||
const axiosStub = {
|
||||
delete: sinon.stub(axios, 'delete'),
|
||||
get: sinon.stub(axios, 'get'),
|
||||
put: sinon.stub(axios, 'put'),
|
||||
};
|
||||
|
||||
describe('UserManagement Component', () => {
|
||||
let userManagement: UserManagementComponentType;
|
||||
let alertService: AlertService;
|
||||
|
||||
beforeEach(() => {
|
||||
axiosStub.put.reset();
|
||||
axiosStub.get.reset();
|
||||
axiosStub.get.resolves({ headers: {} });
|
||||
|
||||
alertService = new AlertService({
|
||||
i18n: { t: vitest.fn() } as any,
|
||||
toast: {
|
||||
show: vitest.fn(),
|
||||
} as any,
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(UserManagement, {
|
||||
global: {
|
||||
stubs: {
|
||||
bPagination: true,
|
||||
jhiItemCount: true,
|
||||
bModal: true,
|
||||
'router-link': true,
|
||||
'jhi-sort-indicator': true,
|
||||
'font-awesome-icon': true,
|
||||
'b-button': true,
|
||||
},
|
||||
directives: {
|
||||
'b-modal': {},
|
||||
},
|
||||
provide: {
|
||||
alertService,
|
||||
currentUsername: ref(''),
|
||||
},
|
||||
},
|
||||
});
|
||||
userManagement = wrapper.vm;
|
||||
});
|
||||
|
||||
describe('OnInit', () => {
|
||||
it('Should call load all on init', async () => {
|
||||
// WHEN
|
||||
userManagement.loadAll();
|
||||
await userManagement.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.get.calledWith(`api/admin/users?sort=id,asc&page=0&size=20`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActive', () => {
|
||||
it('Should update user and call load all', async () => {
|
||||
// GIVEN
|
||||
axiosStub.put.resolves({});
|
||||
|
||||
// WHEN
|
||||
userManagement.setActive({ id: 123 }, true);
|
||||
await userManagement.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.put.calledWith(`api/admin/users`, { id: 123, activated: true })).toBeTruthy();
|
||||
expect(axiosStub.get.calledWith(`api/admin/users?sort=id,asc&page=0&size=20`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmDelete', () => {
|
||||
it('Should call delete service on confirmDelete', async () => {
|
||||
// GIVEN
|
||||
axiosStub.delete.resolves({
|
||||
headers: {
|
||||
'x-smartbookingapp-alert': '',
|
||||
'x-smartbookingapp-params': '',
|
||||
},
|
||||
});
|
||||
|
||||
// WHEN
|
||||
userManagement.prepareRemove({ login: 123 });
|
||||
userManagement.deleteUser();
|
||||
await userManagement.$nextTick();
|
||||
|
||||
// THEN
|
||||
expect(axiosStub.delete.calledWith(`api/admin/users/${123}`)).toBeTruthy();
|
||||
expect(axiosStub.get.calledWith(`api/admin/users?sort=id,asc&page=0&size=20`)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('change order', () => {
|
||||
it('should change order and invert reverse', () => {
|
||||
// WHEN
|
||||
userManagement.changeOrder('dummy-order');
|
||||
|
||||
// THEN
|
||||
expect(userManagement.propOrder).toEqual('dummy-order');
|
||||
expect(userManagement.reverse).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useAlertService } from '@/shared/alert/alert.service';
|
||||
import { useDateFormat } from '@/shared/composables';
|
||||
|
||||
import UserManagementService from './user-management.service';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiUserManagementComponent',
|
||||
setup() {
|
||||
const alertService = inject('alertService', () => useAlertService(), true);
|
||||
const { formatDateShort: formatDate } = useDateFormat();
|
||||
const userManagementService = inject('userManagementService', () => new UserManagementService(), true);
|
||||
const username = inject<ComputedRef<string>>('currentUsername');
|
||||
|
||||
const error = ref('');
|
||||
const success = ref('');
|
||||
const itemsPerPage = ref(20);
|
||||
const page = ref(1);
|
||||
const previousPage = ref(1);
|
||||
const propOrder = ref('id');
|
||||
const reverse = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const removeId: Ref<number> = ref(null);
|
||||
const users: Ref<any[]> = ref([]);
|
||||
const totalItems = ref(0);
|
||||
const queryCount: Ref<number> = ref(null);
|
||||
|
||||
return {
|
||||
formatDate,
|
||||
userManagementService,
|
||||
alertService,
|
||||
error,
|
||||
success,
|
||||
itemsPerPage,
|
||||
page,
|
||||
previousPage,
|
||||
propOrder,
|
||||
reverse,
|
||||
isLoading,
|
||||
removeId,
|
||||
users,
|
||||
username,
|
||||
totalItems,
|
||||
queryCount,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
mounted(): void {
|
||||
this.loadAll();
|
||||
},
|
||||
methods: {
|
||||
setActive(user, isActivated): void {
|
||||
user.activated = isActivated;
|
||||
this.userManagementService
|
||||
.update(user)
|
||||
.then(() => {
|
||||
this.error = null;
|
||||
this.success = 'OK';
|
||||
this.loadAll();
|
||||
})
|
||||
.catch(() => {
|
||||
this.success = null;
|
||||
this.error = 'ERROR';
|
||||
user.activated = false;
|
||||
});
|
||||
},
|
||||
loadAll(): void {
|
||||
this.isLoading = true;
|
||||
|
||||
this.userManagementService
|
||||
.retrieve({
|
||||
sort: this.sort(),
|
||||
page: this.page - 1,
|
||||
size: this.itemsPerPage,
|
||||
})
|
||||
.then(res => {
|
||||
this.isLoading = false;
|
||||
this.users = res.data;
|
||||
this.totalItems = Number(res.headers['x-total-count']);
|
||||
this.queryCount = this.totalItems;
|
||||
})
|
||||
.catch(() => {
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
handleSyncList(): void {
|
||||
this.loadAll();
|
||||
},
|
||||
sort(): any {
|
||||
const result = [`${this.propOrder},${this.reverse ? 'desc' : 'asc'}`];
|
||||
if (this.propOrder !== 'id') {
|
||||
result.push('id');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
loadPage(page: number): void {
|
||||
if (page !== this.previousPage) {
|
||||
this.previousPage = page;
|
||||
this.transition();
|
||||
}
|
||||
},
|
||||
transition(): void {
|
||||
this.loadAll();
|
||||
},
|
||||
changeOrder(propOrder: string): void {
|
||||
this.propOrder = propOrder;
|
||||
this.reverse = !this.reverse;
|
||||
this.transition();
|
||||
},
|
||||
deleteUser(): void {
|
||||
this.userManagementService
|
||||
.remove(this.removeId)
|
||||
.then(res => {
|
||||
this.alertService.showInfo(
|
||||
this.t$(res.headers['x-smartbookingapp-alert'].toString(), {
|
||||
param: decodeURIComponent(res.headers['x-smartbookingapp-params'].replace(/\+/g, ' ')),
|
||||
}),
|
||||
{ variant: 'danger' },
|
||||
);
|
||||
this.removeId = null;
|
||||
this.loadAll();
|
||||
this.closeDialog();
|
||||
})
|
||||
.catch(error => {
|
||||
this.alertService.showHttpError(error.response);
|
||||
});
|
||||
},
|
||||
prepareRemove(instance): void {
|
||||
this.removeId = instance.login;
|
||||
if (<any>this.$refs.removeUser) {
|
||||
(<any>this.$refs.removeUser).show();
|
||||
}
|
||||
},
|
||||
closeDialog(): void {
|
||||
if (<any>this.$refs.removeUser) {
|
||||
(<any>this.$refs.removeUser).hide();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { type IUser } from '@/shared/model/user.model';
|
||||
import buildPaginationQueryOpts from '@/shared/sort/sorts';
|
||||
|
||||
export default class UserManagementService {
|
||||
get(userId: string): Promise<any> {
|
||||
return axios.get(`api/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
create(user: IUser): Promise<any> {
|
||||
return axios.post('api/admin/users', user);
|
||||
}
|
||||
|
||||
update(user: IUser): Promise<any> {
|
||||
return axios.put('api/admin/users', user);
|
||||
}
|
||||
|
||||
remove(userId: number): Promise<any> {
|
||||
return axios.delete(`api/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
retrieve(req?: any): Promise<any> {
|
||||
return axios.get(`api/admin/users?${buildPaginationQueryOpts(req)}`);
|
||||
}
|
||||
|
||||
retrieveAuthorities(): Promise<any> {
|
||||
return axios.get('api/authorities').then(response => {
|
||||
response.data = response.data.map(authority => authority.name);
|
||||
return response;
|
||||
});
|
||||
}
|
||||
}
|
||||
134
src/main/webapp/app/admin/user-management/user-management.vue
Normal file
134
src/main/webapp/app/admin/user-management/user-management.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2>
|
||||
<span id="user-management-page-heading" data-cy="userManagementPageHeading">{{ t$('userManagement.home.title') }}</span>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-info me-2" @click="handleSyncList" :disabled="isLoading">
|
||||
<font-awesome-icon icon="sync" :spin="isLoading"></font-awesome-icon>
|
||||
<span>{{ t$('userManagement.home.refreshListLabel') }}</span>
|
||||
</button>
|
||||
<router-link custom v-slot="{ navigate }" :to="{ name: 'JhiUserCreate' }">
|
||||
<button @click="navigate" class="btn btn-primary jh-create-entity">
|
||||
<font-awesome-icon icon="plus"></font-awesome-icon> <span>{{ t$('userManagement.home.createLabel') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</h2>
|
||||
<div class="table-responsive" v-if="users">
|
||||
<table class="table table-striped" aria-describedby="Users">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" @click="changeOrder('id')">
|
||||
<span>{{ t$('global.field.id') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'id'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col" @click="changeOrder('login')">
|
||||
<span>{{ t$('userManagement.login') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'login'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col" @click="changeOrder('email')">
|
||||
<span>{{ t$('userManagement.email') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'email'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col"></th>
|
||||
<th scope="col" @click="changeOrder('langKey')">
|
||||
<span>{{ t$('userManagement.langKey') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'langKey'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col">
|
||||
<span>{{ t$('userManagement.profiles') }}</span>
|
||||
</th>
|
||||
<th scope="col" @click="changeOrder('createdDate')">
|
||||
<span>{{ t$('userManagement.createdDate') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'createdDate'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col" @click="changeOrder('lastModifiedBy')">
|
||||
<span>{{ t$('userManagement.lastModifiedBy') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'lastModifiedBy'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col" id="modified-date-sort" @click="changeOrder('lastModifiedDate')">
|
||||
<span>{{ t$('userManagement.lastModifiedDate') }}</span>
|
||||
<jhi-sort-indicator :current-order="propOrder" :reverse="reverse" :field-name="'lastModifiedDate'"></jhi-sort-indicator>
|
||||
</th>
|
||||
<th scope="col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-if="users">
|
||||
<tr v-for="user in users" :key="user.id" :id="user.login">
|
||||
<td>
|
||||
<router-link :to="{ name: 'JhiUserView', params: { userId: user.login } }">{{ user.id }}</router-link>
|
||||
</td>
|
||||
<td>{{ user.login }}</td>
|
||||
<td class="jhi-user-email">{{ user.email }}</td>
|
||||
<td>
|
||||
<button class="btn btn-danger btn-sm deactivated" @click="setActive(user, true)" v-if="!user.activated">
|
||||
{{ t$('userManagement.deactivated') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-success btn-sm"
|
||||
@click="setActive(user, false)"
|
||||
v-if="user.activated"
|
||||
:disabled="username === user.login"
|
||||
>
|
||||
{{ t$('userManagement.activated') }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ user.langKey }}</td>
|
||||
<td>
|
||||
<div v-for="authority of user.authorities" :key="authority">
|
||||
<span class="badge bg-info">{{ authority }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDate(user.createdDate) }}</td>
|
||||
<td>{{ user.lastModifiedBy }}</td>
|
||||
<td>{{ formatDate(user.lastModifiedDate) }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group">
|
||||
<router-link :to="{ name: 'JhiUserView', params: { userId: user.login } }" custom v-slot="{ navigate }">
|
||||
<button @click="navigate" class="btn btn-info btn-sm details">
|
||||
<font-awesome-icon icon="eye"></font-awesome-icon>
|
||||
<span class="d-none d-md-inline">{{ t$('entity.action.view') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'JhiUserEdit', params: { userId: user.login } }" custom v-slot="{ navigate }">
|
||||
<button @click="navigate" class="btn btn-primary btn-sm edit">
|
||||
<font-awesome-icon icon="pencil-alt"></font-awesome-icon>
|
||||
<span class="d-none d-md-inline">{{ t$('entity.action.edit') }}</span>
|
||||
</button>
|
||||
</router-link>
|
||||
<b-button @click="prepareRemove(user)" variant="danger" class="btn btn-sm delete" :disabled="username === user.login">
|
||||
<font-awesome-icon icon="times"></font-awesome-icon>
|
||||
<span class="d-none d-md-inline">{{ t$('entity.action.delete') }}</span>
|
||||
</b-button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<b-modal ref="removeUser" id="removeUser" :title="t$('entity.delete.title')" @ok="deleteUser()">
|
||||
<div class="modal-body">
|
||||
<p id="jhi-delete-user-heading">{{ t$('userManagement.delete.question', { login: removeId }) }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div>
|
||||
<button type="button" class="btn btn-secondary" @click="closeDialog()">{{ t$('entity.action.cancel') }}</button>
|
||||
<button type="button" class="btn btn-primary" id="confirm-delete-user" @click="deleteUser()">
|
||||
{{ t$('entity.action.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</b-modal>
|
||||
</div>
|
||||
<div v-show="users?.length > 0">
|
||||
<div class="d-flex justify-content-center">
|
||||
<jhi-item-count :page="page" :total="queryCount" :items-per-page="itemsPerPage"></jhi-item-count>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<b-pagination size="md" :total-rows="totalItems" v-model="page" :per-page="itemsPerPage" :change="loadPage(page)"></b-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./user-management.component.ts"></script>
|
||||
33
src/main/webapp/app/app.component.ts
Normal file
33
src/main/webapp/app/app.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { defineComponent, provide } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { BToastOrchestrator } from 'bootstrap-vue-next';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import LoginForm from '@/account/login-form/login-form.vue';
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import JhiFooter from '@/core/jhi-footer/jhi-footer.vue';
|
||||
import JhiNavbar from '@/core/jhi-navbar/jhi-navbar.vue';
|
||||
import Ribbon from '@/core/ribbon/ribbon.vue';
|
||||
import { useAlertService } from '@/shared/alert/alert.service';
|
||||
import '@/shared/config/dayjs';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
components: {
|
||||
BToastOrchestrator,
|
||||
Ribbon,
|
||||
JhiNavbar,
|
||||
LoginForm,
|
||||
JhiFooter,
|
||||
},
|
||||
setup() {
|
||||
provide('alertService', useAlertService());
|
||||
const { loginModalOpen } = storeToRefs(useLoginModal());
|
||||
|
||||
return {
|
||||
loginModalOpen,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
23
src/main/webapp/app/app.vue
Normal file
23
src/main/webapp/app/app.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<BToastOrchestrator />
|
||||
<div id="app">
|
||||
<ribbon></ribbon>
|
||||
<div id="app-header">
|
||||
<jhi-navbar></jhi-navbar>
|
||||
</div>
|
||||
<div class="container-fluid">
|
||||
<div class="card jh-card">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
<b-modal id="login-page" focus="username" v-model="loginModalOpen" :no-footer="true" lazy>
|
||||
<template #title>
|
||||
<span data-cy="loginTitle" id="login-title">{{ t$('login.title') }}</span>
|
||||
</template>
|
||||
<login-form v-if="loginModalOpen"></login-form>
|
||||
</b-modal>
|
||||
<jhi-footer></jhi-footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./app.component.ts"></script>
|
||||
4
src/main/webapp/app/constants.ts
Normal file
4
src/main/webapp/app/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Errors
|
||||
export const PROBLEM_BASE_URL = 'https://www.jhipster.tech/problem';
|
||||
export const EMAIL_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/email-already-used`;
|
||||
export const LOGIN_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/login-already-used`;
|
||||
95
src/main/webapp/app/core/error/error.component.spec.ts
Normal file
95
src/main/webapp/app/core/error/error.component.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { vitest } from 'vitest';
|
||||
import { type Ref, ref } from 'vue';
|
||||
import { type RouteLocation } from 'vue-router';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { type ComponentMountingOptions, shallowMount } from '@vue/test-utils';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
|
||||
import Error from './error.vue';
|
||||
|
||||
type ErrorComponentType = InstanceType<typeof Error>;
|
||||
|
||||
let route: Partial<RouteLocation>;
|
||||
|
||||
vitest.mock('vue-router', () => ({
|
||||
useRoute: () => route,
|
||||
}));
|
||||
|
||||
const customErrorMsg = 'An error occurred.';
|
||||
|
||||
describe('Error component', () => {
|
||||
let error: ErrorComponentType;
|
||||
let login: ReturnType<typeof useLoginModal>;
|
||||
let authenticated: Ref<boolean>;
|
||||
let mountOptions: ComponentMountingOptions<ErrorComponentType>;
|
||||
|
||||
beforeEach(() => {
|
||||
route = {};
|
||||
authenticated = ref(false);
|
||||
mountOptions = {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
provide: {
|
||||
authenticated,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should have retrieve custom error on routing', () => {
|
||||
route = {
|
||||
path: '/custom-error',
|
||||
name: 'CustomMessage',
|
||||
meta: { errorMessage: customErrorMsg },
|
||||
};
|
||||
const wrapper = shallowMount(Error, mountOptions);
|
||||
error = wrapper.vm;
|
||||
login = useLoginModal();
|
||||
|
||||
expect(error.errorMessage).toBe(customErrorMsg);
|
||||
expect(error.error403).toBeFalsy();
|
||||
expect(error.error404).toBeFalsy();
|
||||
expect(login.showLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have set forbidden error on routing', () => {
|
||||
route = {
|
||||
meta: { error403: true },
|
||||
};
|
||||
const wrapper = shallowMount(Error, mountOptions);
|
||||
error = wrapper.vm;
|
||||
login = useLoginModal();
|
||||
|
||||
expect(error.errorMessage).toBeNull();
|
||||
expect(error.error403).toBeTruthy();
|
||||
expect(error.error404).toBeFalsy();
|
||||
expect(login.showLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have set not found error on routing', () => {
|
||||
route = {
|
||||
meta: { error404: true },
|
||||
};
|
||||
const wrapper = shallowMount(Error, mountOptions);
|
||||
error = wrapper.vm;
|
||||
login = useLoginModal();
|
||||
|
||||
expect(error.errorMessage).toBeNull();
|
||||
expect(error.error403).toBeFalsy();
|
||||
expect(error.error404).toBeTruthy();
|
||||
expect(login.showLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should have set default on no error', () => {
|
||||
const wrapper = shallowMount(Error, mountOptions);
|
||||
error = wrapper.vm;
|
||||
login = useLoginModal();
|
||||
|
||||
expect(error.errorMessage).toBeNull();
|
||||
expect(error.error403).toBeFalsy();
|
||||
expect(error.error404).toBeFalsy();
|
||||
expect(login.showLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
33
src/main/webapp/app/core/error/error.component.ts
Normal file
33
src/main/webapp/app/core/error/error.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type ComputedRef, type Ref, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Error',
|
||||
setup() {
|
||||
const { showLogin } = useLoginModal();
|
||||
const authenticated = inject<ComputedRef<boolean>>('authenticated');
|
||||
const errorMessage: Ref<string> = ref(null);
|
||||
const error403: Ref<boolean> = ref(false);
|
||||
const error404: Ref<boolean> = ref(false);
|
||||
const route = useRoute();
|
||||
|
||||
if (route.meta) {
|
||||
errorMessage.value = route.meta.errorMessage ?? null;
|
||||
error403.value = route.meta.error403 ?? false;
|
||||
error404.value = route.meta.error404 ?? false;
|
||||
if (!authenticated.value && error403.value) {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
error403,
|
||||
error404,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
20
src/main/webapp/app/core/error/error.vue
Normal file
20
src/main/webapp/app/core/error/error.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="row-md">
|
||||
<div class="col-md-3">
|
||||
<span class="hipster img-fluid rounded"></span>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h1>{{ t$('error.title') }}</h1>
|
||||
|
||||
<div v-if="errorMessage">
|
||||
<div class="alert alert-danger">{{ errorMessage }}</div>
|
||||
</div>
|
||||
<div v-if="error403" class="alert alert-danger">{{ t$('error.http.403') }}</div>
|
||||
<div v-if="error404" class="alert alert-warning">{{ t$('error.http.404') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./error.component.ts"></script>
|
||||
55
src/main/webapp/app/core/home/home.component.spec.ts
Normal file
55
src/main/webapp/app/core/home/home.component.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
|
||||
import Home from './home.vue';
|
||||
|
||||
type HomeComponentType = InstanceType<typeof Home>;
|
||||
|
||||
describe('Home', () => {
|
||||
let home: HomeComponentType;
|
||||
let authenticated;
|
||||
let currentUsername;
|
||||
let login: ReturnType<typeof useLoginModal>;
|
||||
|
||||
beforeEach(() => {
|
||||
authenticated = ref(false);
|
||||
currentUsername = ref('');
|
||||
const wrapper = shallowMount(Home, {
|
||||
global: {
|
||||
plugins: [createTestingPinia()],
|
||||
stubs: {
|
||||
'router-link': true,
|
||||
},
|
||||
provide: {
|
||||
authenticated,
|
||||
currentUsername,
|
||||
},
|
||||
},
|
||||
});
|
||||
home = wrapper.vm;
|
||||
login = useLoginModal();
|
||||
});
|
||||
|
||||
it('should not have user data set', () => {
|
||||
expect(home.authenticated).toBeFalsy();
|
||||
expect(home.username).toBe('');
|
||||
});
|
||||
|
||||
it('should have user data set after authentication', () => {
|
||||
authenticated.value = true;
|
||||
currentUsername.value = 'test';
|
||||
|
||||
expect(home.authenticated).toBeTruthy();
|
||||
expect(home.username).toBe('test');
|
||||
});
|
||||
|
||||
it('should use login service', () => {
|
||||
home.showLogin();
|
||||
expect(login.showLogin).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
19
src/main/webapp/app/core/home/home.component.ts
Normal file
19
src/main/webapp/app/core/home/home.component.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { type ComputedRef, defineComponent, inject } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
|
||||
export default defineComponent({
|
||||
setup() {
|
||||
const { showLogin } = useLoginModal();
|
||||
const authenticated = inject<ComputedRef<boolean>>('authenticated');
|
||||
const username = inject<ComputedRef<string>>('currentUsername');
|
||||
|
||||
return {
|
||||
authenticated,
|
||||
username,
|
||||
showLogin,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
60
src/main/webapp/app/core/home/home.vue
Normal file
60
src/main/webapp/app/core/home/home.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="home row">
|
||||
<div class="col-md-3">
|
||||
<span class="hipster img-fluid rounded"></span>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<h1 class="display-4">{{ t$('home.title') }}</h1>
|
||||
<p class="lead">{{ t$('home.subtitle') }}</p>
|
||||
|
||||
<div>
|
||||
<div class="alert alert-success" v-if="authenticated">
|
||||
<span v-if="username">{{ t$('home.logged.message', { username }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" v-if="!authenticated">
|
||||
<span>{{ t$('global.messages.info.authenticated.prefix') }}</span>
|
||||
<a class="alert-link" @click="showLogin()">{{ t$('global.messages.info.authenticated.link') }}</a
|
||||
><span v-html="t$('global.messages.info.authenticated.suffix')"></span>
|
||||
</div>
|
||||
<div class="alert alert-warning" v-if="!authenticated">
|
||||
<span>{{ t$('global.messages.info.register.noaccount') }}</span
|
||||
>
|
||||
<router-link class="alert-link" to="/register">{{ t$('global.messages.info.register.link') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>{{ t$('home.question') }}</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://www.jhipster.tech/" target="_blank" rel="noopener noreferrer">{{ t$('home.link.homepage') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://stackoverflow.com/tags/jhipster/info" target="_blank" rel="noopener noreferrer">{{
|
||||
t$('home.link.stackoverflow')
|
||||
}}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://github.com/jhipster/generator-jhipster/issues?state=open" target="_blank" rel="noopener noreferrer">{{
|
||||
t$('home.link.bugtracker')
|
||||
}}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://gitter.im/jhipster/generator-jhipster" target="_blank" rel="noopener noreferrer">{{ t$('home.link.chat') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://twitter.com/jhipster" target="_blank" rel="noopener noreferrer">{{ t$('home.link.follow') }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<span>{{ t$('home.like') }}</span>
|
||||
<a href="https://github.com/jhipster/generator-jhipster" target="_blank" rel="noopener noreferrer">{{ t$('home.github') }}</a
|
||||
>!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./home.component.ts"></script>
|
||||
11
src/main/webapp/app/core/jhi-footer/jhi-footer.component.ts
Normal file
11
src/main/webapp/app/core/jhi-footer/jhi-footer.component.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiFooter',
|
||||
setup() {
|
||||
return {
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
7
src/main/webapp/app/core/jhi-footer/jhi-footer.vue
Normal file
7
src/main/webapp/app/core/jhi-footer/jhi-footer.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div id="footer" class="footer">
|
||||
<p>{{ t$('footer') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./jhi-footer.component.ts"></script>
|
||||
115
src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.spec.ts
Normal file
115
src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { vitest } from 'vitest';
|
||||
import { computed } from 'vue';
|
||||
import { type Router } from 'vue-router';
|
||||
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import type LoginService from '@/account/login.service';
|
||||
import { createRouter } from '@/router';
|
||||
import { useStore } from '@/store';
|
||||
|
||||
import JhiNavbar from './jhi-navbar.vue';
|
||||
|
||||
type JhiNavbarComponentType = InstanceType<typeof JhiNavbar>;
|
||||
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
const store = useStore();
|
||||
|
||||
describe('JhiNavbar', () => {
|
||||
let jhiNavbar: JhiNavbarComponentType;
|
||||
let loginService: LoginService;
|
||||
let login: ReturnType<typeof useLoginModal>;
|
||||
const accountService = { hasAnyAuthorityAndCheckAuth: vitest.fn().mockImplementation(() => Promise.resolve(true)) };
|
||||
const changeLanguage = vitest.fn();
|
||||
let router: Router;
|
||||
|
||||
beforeEach(() => {
|
||||
router = createRouter();
|
||||
loginService = { login: vitest.fn(), logout: vitest.fn() };
|
||||
const wrapper = shallowMount(JhiNavbar, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'font-awesome-icon': true,
|
||||
'b-navbar': true,
|
||||
'b-navbar-nav': true,
|
||||
'b-dropdown-item': true,
|
||||
'b-collapse': true,
|
||||
'b-nav-item': true,
|
||||
'b-nav-item-dropdown': true,
|
||||
'b-navbar-toggle': true,
|
||||
'b-navbar-brand': true,
|
||||
},
|
||||
provide: {
|
||||
loginService,
|
||||
currentLanguage: computed(() => 'foo'),
|
||||
changeLanguage,
|
||||
accountService,
|
||||
},
|
||||
},
|
||||
});
|
||||
jhiNavbar = wrapper.vm;
|
||||
login = useLoginModal();
|
||||
});
|
||||
|
||||
it('should not have user data set', () => {
|
||||
expect(jhiNavbar.authenticated).toBeFalsy();
|
||||
expect(jhiNavbar.openAPIEnabled).toBeFalsy();
|
||||
expect(jhiNavbar.inProduction).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should have user data set after authentication', () => {
|
||||
store.setAuthentication({ login: 'test' });
|
||||
|
||||
expect(jhiNavbar.authenticated).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have profile info set after info retrieved', () => {
|
||||
store.setActiveProfiles(['prod', 'api-docs']);
|
||||
|
||||
expect(jhiNavbar.openAPIEnabled).toBeTruthy();
|
||||
expect(jhiNavbar.inProduction).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should use login service', () => {
|
||||
jhiNavbar.showLogin();
|
||||
|
||||
expect(login.showLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use account service', () => {
|
||||
jhiNavbar.hasAnyAuthority('auth');
|
||||
|
||||
expect(accountService.hasAnyAuthorityAndCheckAuth).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logout should clear credentials', async () => {
|
||||
store.setAuthentication({ login: 'test' });
|
||||
(loginService.logout as any).mockReturnValue(Promise.resolve({}));
|
||||
|
||||
await jhiNavbar.logout();
|
||||
|
||||
expect(loginService.logout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should determine active route', async () => {
|
||||
await router.push('/forbidden');
|
||||
|
||||
expect(jhiNavbar.subIsActive('/titi')).toBeFalsy();
|
||||
expect(jhiNavbar.subIsActive('/forbidden')).toBeTruthy();
|
||||
expect(jhiNavbar.subIsActive(['/forbidden', 'forbidden'])).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should call translationService when changing language', () => {
|
||||
jhiNavbar.changeLanguage('fr');
|
||||
|
||||
expect(changeLanguage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should check for correct language', () => {
|
||||
expect(jhiNavbar.isActiveLanguage('en')).toBeFalsy();
|
||||
expect(jhiNavbar.isActiveLanguage('foo')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
81
src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.ts
Normal file
81
src/main/webapp/app/core/jhi-navbar/jhi-navbar.component.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { type Ref, computed, defineComponent, inject, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import type AccountService from '@/account/account.service';
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import type LoginService from '@/account/login.service';
|
||||
import EntitiesMenu from '@/entities/entities-menu.vue';
|
||||
import languages from '@/shared/config/languages';
|
||||
import { useStore } from '@/store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'JhiNavbar',
|
||||
components: {
|
||||
'entities-menu': EntitiesMenu,
|
||||
},
|
||||
setup() {
|
||||
const loginService = inject<LoginService>('loginService');
|
||||
|
||||
const { showLogin } = useLoginModal();
|
||||
const accountService = inject<AccountService>('accountService');
|
||||
const currentLanguage = inject('currentLanguage', () => computed(() => navigator.language ?? 'it'), true);
|
||||
const changeLanguage = inject<(string) => Promise<void>>('changeLanguage');
|
||||
|
||||
const isActiveLanguage = (key: string) => {
|
||||
return key === currentLanguage.value;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const version = `v${APP_VERSION}`;
|
||||
const hasAnyAuthorityValues: Ref<any> = ref({});
|
||||
|
||||
const openAPIEnabled = computed(() => store.activeProfiles.indexOf('api-docs') > -1);
|
||||
const inProduction = computed(() => store.activeProfiles.indexOf('prod') > -1);
|
||||
const authenticated = computed(() => store.authenticated);
|
||||
|
||||
const subIsActive = (input: string | string[]) => {
|
||||
const paths = Array.isArray(input) ? input : [input];
|
||||
return paths.some(path => {
|
||||
return router.currentRoute.value.path.startsWith(path); // current path starts with this path string
|
||||
});
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const response = await loginService.logout();
|
||||
store.logout();
|
||||
if (router.currentRoute.value.path !== '/') {
|
||||
await router.push('/');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
logout,
|
||||
subIsActive,
|
||||
accountService,
|
||||
showLogin,
|
||||
changeLanguage,
|
||||
languages: languages(),
|
||||
isActiveLanguage,
|
||||
version,
|
||||
currentLanguage,
|
||||
hasAnyAuthorityValues,
|
||||
openAPIEnabled,
|
||||
inProduction,
|
||||
authenticated,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
hasAnyAuthority(authorities: any): boolean {
|
||||
this.accountService.hasAnyAuthorityAndCheckAuth(authorities).then(value => {
|
||||
if (this.hasAnyAuthorityValues[authorities] !== value) {
|
||||
this.hasAnyAuthorityValues = { ...this.hasAnyAuthorityValues, [authorities]: value };
|
||||
}
|
||||
});
|
||||
return this.hasAnyAuthorityValues[authorities] ?? false;
|
||||
},
|
||||
},
|
||||
});
|
||||
199
src/main/webapp/app/core/jhi-navbar/jhi-navbar.vue
Normal file
199
src/main/webapp/app/core/jhi-navbar/jhi-navbar.vue
Normal file
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<b-navbar data-cy="navbar" toggleable="md" variant="dark" data-bs-theme="dark">
|
||||
<b-navbar-brand class="logo" b-link to="/">
|
||||
<span class="logo-img"></span>
|
||||
<span class="navbar-title">{{ t$('global.title') }}</span> <span class="navbar-version">{{ version }}</span>
|
||||
</b-navbar-brand>
|
||||
<b-navbar-toggle
|
||||
right
|
||||
class="jh-navbar-toggler d-lg-none"
|
||||
href="javascript:void(0);"
|
||||
data-toggle="collapse"
|
||||
target="header-tabs"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<font-awesome-icon icon="bars" />
|
||||
</b-navbar-toggle>
|
||||
|
||||
<b-collapse is-nav id="header-tabs">
|
||||
<b-navbar-nav class="ms-auto">
|
||||
<b-nav-item to="/" exact>
|
||||
<span>
|
||||
<font-awesome-icon icon="fa-solid fa-home" />
|
||||
<span>{{ t$('global.menu.home') }}</span>
|
||||
</span>
|
||||
</b-nav-item>
|
||||
<b-nav-item-dropdown
|
||||
no-size="true"
|
||||
end
|
||||
id="entity-menu"
|
||||
v-if="authenticated"
|
||||
active-class="active"
|
||||
class="pointer"
|
||||
data-cy="entity"
|
||||
>
|
||||
<template #button-content>
|
||||
<span class="navbar-dropdown-menu">
|
||||
<font-awesome-icon icon="th-list" />
|
||||
<span class="no-bold">{{ t$('global.menu.entities.main') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<entities-menu></entities-menu>
|
||||
<!-- jhipster-needle-add-entity-to-menu - JHipster will add entities to the menu here -->
|
||||
</b-nav-item-dropdown>
|
||||
<b-nav-item-dropdown
|
||||
right
|
||||
id="admin-menu"
|
||||
v-if="hasAnyAuthority('ROLE_ADMIN') && authenticated"
|
||||
:class="{ 'router-link-active': subIsActive('/admin') }"
|
||||
active-class="active"
|
||||
class="pointer"
|
||||
data-cy="adminMenu"
|
||||
>
|
||||
<template #button-content>
|
||||
<span class="navbar-dropdown-menu">
|
||||
<font-awesome-icon icon="users-cog" />
|
||||
<span class="no-bold">{{ t$('global.menu.admin.main') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<b-dropdown-item to="/admin/user-management" active-class="active">
|
||||
<font-awesome-icon icon="users" />
|
||||
<span>{{ t$('global.menu.admin.userManagement') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item to="/admin/metrics" active-class="active">
|
||||
<font-awesome-icon icon="tachometer-alt" />
|
||||
<span>{{ t$('global.menu.admin.metrics') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item to="/admin/health" active-class="active">
|
||||
<font-awesome-icon icon="heart" />
|
||||
<span>{{ t$('global.menu.admin.health') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item to="/admin/configuration" active-class="active">
|
||||
<font-awesome-icon icon="cogs" />
|
||||
<span>{{ t$('global.menu.admin.configuration') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item to="/admin/logs" active-class="active">
|
||||
<font-awesome-icon icon="tasks" />
|
||||
<span>{{ t$('global.menu.admin.logs') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item v-if="openAPIEnabled" to="/admin/docs" active-class="active">
|
||||
<font-awesome-icon icon="book" />
|
||||
<span>{{ t$('global.menu.admin.apidocs') }}</span>
|
||||
</b-dropdown-item>
|
||||
</b-nav-item-dropdown>
|
||||
<b-nav-item-dropdown id="languagesnavBarDropdown" end v-if="languages && Object.keys(languages).length > 1">
|
||||
<template #button-content>
|
||||
<font-awesome-icon icon="flag" />
|
||||
<span class="no-bold">{{ t$('global.menu.language') }}</span>
|
||||
</template>
|
||||
<b-dropdown-item
|
||||
v-for="(value, key) in languages"
|
||||
:key="`lang-${key}`"
|
||||
@click="changeLanguage(key)"
|
||||
:class="{ active: isActiveLanguage(key) }"
|
||||
>
|
||||
{{ value.name }}
|
||||
</b-dropdown-item>
|
||||
</b-nav-item-dropdown>
|
||||
<b-nav-item-dropdown
|
||||
right
|
||||
href="javascript:void(0);"
|
||||
id="account-menu"
|
||||
:class="{ 'router-link-active': subIsActive('/account') }"
|
||||
active-class="active"
|
||||
class="pointer"
|
||||
data-cy="accountMenu"
|
||||
>
|
||||
<template #button-content>
|
||||
<span class="navbar-dropdown-menu">
|
||||
<font-awesome-icon icon="user" />
|
||||
<span class="no-bold">{{ t$('global.menu.account.main') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<b-dropdown-item data-cy="settings" to="/account/settings" v-if="authenticated" active-class="active">
|
||||
<font-awesome-icon icon="wrench" />
|
||||
<span>{{ t$('global.menu.account.settings') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item data-cy="passwordItem" to="/account/password" v-if="authenticated" active-class="active">
|
||||
<font-awesome-icon icon="lock" />
|
||||
<span>{{ t$('global.menu.account.password') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item to="/account/sessions" v-if="authenticated" active-class="active">
|
||||
<font-awesome-icon icon="cloud" />
|
||||
<span>{{ t$('global.menu.account.sessions') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item data-cy="logout" v-if="authenticated" @click="logout()" id="logout" active-class="active">
|
||||
<font-awesome-icon icon="sign-out-alt" />
|
||||
<span>{{ t$('global.menu.account.logout') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item data-cy="login" v-if="!authenticated" @click="showLogin()" id="login" active-class="active">
|
||||
<font-awesome-icon icon="sign-in-alt" />
|
||||
<span>{{ t$('global.menu.account.login') }}</span>
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item data-cy="register" to="/register" id="register" v-if="!authenticated" active-class="active">
|
||||
<font-awesome-icon icon="user-plus" />
|
||||
<span>{{ t$('global.menu.account.register') }}</span>
|
||||
</b-dropdown-item>
|
||||
</b-nav-item-dropdown>
|
||||
</b-navbar-nav>
|
||||
</b-collapse>
|
||||
</b-navbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./jhi-navbar.component.ts"></script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
Navbar
|
||||
========================================================================== */
|
||||
.navbar-version {
|
||||
font-size: 0.65em;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.navbar .navbar-nav .nav-item {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
.jh-navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 768px) and (max-width: 1150px) {
|
||||
span span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-title {
|
||||
display: inline-block;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Logo styles
|
||||
========================================================================== */
|
||||
.navbar-brand.logo {
|
||||
padding: 0 7px;
|
||||
}
|
||||
|
||||
.logo .logo-img {
|
||||
height: 45px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 45px;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
height: 100%;
|
||||
background: url('/content/images/logo-jhipster.png') no-repeat center center;
|
||||
background-size: contain;
|
||||
width: 100%;
|
||||
filter: drop-shadow(0 0 0.05rem white);
|
||||
margin: 0 5px;
|
||||
}
|
||||
</style>
|
||||
45
src/main/webapp/app/core/ribbon/ribbon.component.spec.ts
Normal file
45
src/main/webapp/app/core/ribbon/ribbon.component.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import { type AccountStore, useStore } from '@/store';
|
||||
|
||||
import Ribbon from './ribbon.vue';
|
||||
|
||||
type RibbonComponentType = InstanceType<typeof Ribbon>;
|
||||
|
||||
const pinia = createTestingPinia({ stubActions: false });
|
||||
|
||||
describe('Ribbon', () => {
|
||||
let ribbon: RibbonComponentType;
|
||||
let store: AccountStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
const wrapper = shallowMount(Ribbon, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
},
|
||||
});
|
||||
ribbon = wrapper.vm;
|
||||
await ribbon.$nextTick();
|
||||
store = useStore();
|
||||
store.setRibbonOnProfiles(null);
|
||||
});
|
||||
|
||||
it('should not have ribbonEnabled when no data', () => {
|
||||
expect(ribbon.ribbonEnabled).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should have ribbonEnabled set to value in store', () => {
|
||||
const profile = 'dev';
|
||||
store.setActiveProfiles(['foo', profile, 'bar']);
|
||||
store.setRibbonOnProfiles(profile);
|
||||
expect(ribbon.ribbonEnabled).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should not have ribbonEnabled when profile not activated', () => {
|
||||
const profile = 'dev';
|
||||
store.setActiveProfiles(['foo', 'bar']);
|
||||
store.setRibbonOnProfiles(profile);
|
||||
expect(ribbon.ribbonEnabled).toBeFalsy();
|
||||
});
|
||||
});
|
||||
19
src/main/webapp/app/core/ribbon/ribbon.component.ts
Normal file
19
src/main/webapp/app/core/ribbon/ribbon.component.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { useStore } from '@/store';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Ribbon',
|
||||
setup() {
|
||||
const store = useStore();
|
||||
const ribbonEnv = computed(() => store.ribbonOnProfiles);
|
||||
const ribbonEnabled = computed(() => store.ribbonOnProfiles && store.activeProfiles.indexOf(store.ribbonOnProfiles) > -1);
|
||||
|
||||
return {
|
||||
ribbonEnv,
|
||||
ribbonEnabled,
|
||||
t$: useI18n().t,
|
||||
};
|
||||
},
|
||||
});
|
||||
43
src/main/webapp/app/core/ribbon/ribbon.vue
Normal file
43
src/main/webapp/app/core/ribbon/ribbon.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="ribbon" v-if="ribbonEnabled">
|
||||
<a href="">{{ t$('global.ribbon.' + ribbonEnv) }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./ribbon.component.ts"></script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
/* ==========================================================================
|
||||
Development Ribbon
|
||||
========================================================================== */
|
||||
.ribbon {
|
||||
background-color: rgba(170, 0, 0, 0.5);
|
||||
left: -3.5em;
|
||||
-moz-transform: rotate(-45deg);
|
||||
-ms-transform: rotate(-45deg);
|
||||
-o-transform: rotate(-45deg);
|
||||
-webkit-transform: rotate(-45deg);
|
||||
transform: rotate(-45deg);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
white-space: nowrap;
|
||||
width: 15em;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.ribbon a {
|
||||
color: #fff;
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
margin: 1px 0;
|
||||
padding: 10px 50px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 0 5px #444;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
7
src/main/webapp/app/declarations.d.ts
vendored
Normal file
7
src/main/webapp/app/declarations.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// These constants are injected via webpack environment variables.
|
||||
// You can add more variables in webpack.common.js or in profile specific webpack.<dev|prod>.js files.
|
||||
// If you change the values in the webpack config files, you need to re run webpack to update the application
|
||||
|
||||
declare const SERVER_API_URL: string;
|
||||
declare const APP_VERSION: string;
|
||||
declare const I18N_HASH: string;
|
||||
12
src/main/webapp/app/entities/entities-menu.component.ts
Normal file
12
src/main/webapp/app/entities/entities-menu.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'EntitiesMenu',
|
||||
setup() {
|
||||
const i18n = useI18n();
|
||||
return {
|
||||
t$: i18n.t,
|
||||
};
|
||||
},
|
||||
});
|
||||
7
src/main/webapp/app/entities/entities-menu.vue
Normal file
7
src/main/webapp/app/entities/entities-menu.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- jhipster-needle-add-entity-to-menu - JHipster will add entities to the menu here -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./entities-menu.component.ts"></script>
|
||||
12
src/main/webapp/app/entities/entities.component.ts
Normal file
12
src/main/webapp/app/entities/entities.component.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineComponent, provide } from 'vue';
|
||||
|
||||
import UserService from '@/entities/user/user.service';
|
||||
// jhipster-needle-add-entity-service-to-entities-component-import - JHipster will import entities services here
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Entities',
|
||||
setup() {
|
||||
provide('userService', () => new UserService());
|
||||
// jhipster-needle-add-entity-service-to-entities-component - JHipster will import entities services here
|
||||
},
|
||||
});
|
||||
5
src/main/webapp/app/entities/entities.vue
Normal file
5
src/main/webapp/app/entities/entities.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="./entities.component.ts"></script>
|
||||
9
src/main/webapp/app/entities/user/user.service.ts
Normal file
9
src/main/webapp/app/entities/user/user.service.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const baseApiUrl = 'api/users';
|
||||
|
||||
export default class UserService {
|
||||
retrieve(): Promise<any> {
|
||||
return axios.get(baseApiUrl);
|
||||
}
|
||||
}
|
||||
37
src/main/webapp/app/locale/translation.service.ts
Normal file
37
src/main/webapp/app/locale/translation.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { type Composer } from 'vue-i18n';
|
||||
|
||||
import axios from 'axios';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import languages from '@/shared/config/languages';
|
||||
|
||||
export default class TranslationService {
|
||||
private readonly i18n: Composer;
|
||||
private languages = languages();
|
||||
|
||||
constructor(i18n: Composer) {
|
||||
this.i18n = i18n;
|
||||
}
|
||||
|
||||
async refreshTranslation(newLanguage: string) {
|
||||
if (this.i18n && !this.i18n.messages[newLanguage]) {
|
||||
const translations = (await import(`../../i18n/${newLanguage}/${newLanguage}.js`)).default;
|
||||
this.i18n.setLocaleMessage(newLanguage, translations);
|
||||
}
|
||||
}
|
||||
|
||||
setLocale(lang: string) {
|
||||
dayjs.locale(lang);
|
||||
this.i18n.locale.value = lang;
|
||||
axios.defaults.headers.common['Accept-Language'] = lang;
|
||||
document.querySelector('html').setAttribute('lang', lang);
|
||||
}
|
||||
|
||||
isLanguageSupported(lang: string) {
|
||||
return Boolean(this.languages[lang]);
|
||||
}
|
||||
|
||||
getLocalStoreLanguage(): string | null {
|
||||
return localStorage.getItem('currentLanguage');
|
||||
}
|
||||
}
|
||||
132
src/main/webapp/app/main.ts
Normal file
132
src/main/webapp/app/main.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
// The Vue build version to load with the `import` command
|
||||
// (runtime-only or standalone) has been set in webpack.common with an alias.
|
||||
import { computed, createApp, onMounted, provide, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import { createPinia, storeToRefs } from 'pinia';
|
||||
|
||||
import AccountService from '@/account/account.service';
|
||||
import { useLoginModal } from '@/account/login-modal';
|
||||
import LoginService from '@/account/login.service';
|
||||
import TranslationService from '@/locale/translation.service';
|
||||
import { setupAxiosInterceptors } from '@/shared/config/axios-interceptor';
|
||||
import { initFortAwesome, initI18N } from '@/shared/config/config';
|
||||
import { initBootstrapVue } from '@/shared/config/config-bootstrap-vue';
|
||||
import JhiItemCount from '@/shared/jhi-item-count.vue';
|
||||
import JhiSortIndicator from '@/shared/sort/jhi-sort-indicator.vue';
|
||||
import { useStore, useTranslationStore } from '@/store';
|
||||
|
||||
import App from './app.vue';
|
||||
import router from './router';
|
||||
|
||||
import '../content/scss/global.scss';
|
||||
import '../content/scss/vendor.scss';
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
// jhipster-needle-add-entity-service-to-main-import - JHipster will import entities services here
|
||||
|
||||
const i18n = initI18N();
|
||||
|
||||
const app = createApp({
|
||||
components: { App },
|
||||
setup() {
|
||||
provide('loginService', new LoginService());
|
||||
const { hideLogin, showLogin } = useLoginModal();
|
||||
const store = useStore();
|
||||
const accountService = new AccountService(store);
|
||||
const i18n = useI18n();
|
||||
const translationStore = useTranslationStore();
|
||||
const translationService = new TranslationService(i18n);
|
||||
|
||||
const changeLanguage = async (newLanguage: string) => {
|
||||
if (i18n.locale.value !== newLanguage) {
|
||||
await translationService.refreshTranslation(newLanguage);
|
||||
translationStore.setCurrentLanguage(newLanguage);
|
||||
}
|
||||
};
|
||||
|
||||
provide('currentLanguage', i18n.locale);
|
||||
provide('changeLanguage', changeLanguage);
|
||||
|
||||
watch(
|
||||
() => store.account,
|
||||
async value => {
|
||||
if (!translationService.getLocalStoreLanguage()) {
|
||||
await changeLanguage(value.langKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => translationStore.currentLanguage,
|
||||
value => {
|
||||
translationService.setLocale(value);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
const lang = [translationService.getLocalStoreLanguage(), store.account?.langKey, navigator.language, 'it'].find(
|
||||
lang => lang && translationService.isLanguageSupported(lang),
|
||||
);
|
||||
await changeLanguage(lang);
|
||||
});
|
||||
|
||||
router.beforeResolve(async (to, from, next) => {
|
||||
// Make sure login modal is closed
|
||||
hideLogin();
|
||||
|
||||
if (!store.authenticated) {
|
||||
await accountService.update();
|
||||
}
|
||||
if (to.meta?.authorities && to.meta.authorities.length > 0) {
|
||||
const value = await accountService.hasAnyAuthorityAndCheckAuth(to.meta.authorities);
|
||||
if (!value) {
|
||||
if (from.path !== '/forbidden') {
|
||||
next({ path: '/forbidden' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
setupAxiosInterceptors(
|
||||
error => {
|
||||
const url = error.response?.config?.url;
|
||||
const status = error.status || error.response?.status;
|
||||
if (status === 401) {
|
||||
// Store logged out state.
|
||||
store.logout();
|
||||
if (!url.endsWith('api/account') && !url.endsWith('api/authentication')) {
|
||||
// Ask for a new authentication
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
const { authenticated } = storeToRefs(store);
|
||||
provide('authenticated', authenticated);
|
||||
provide(
|
||||
'currentUsername',
|
||||
computed(() => store.account?.login),
|
||||
);
|
||||
|
||||
provide('translationService', translationService);
|
||||
provide('accountService', accountService);
|
||||
// jhipster-needle-add-entity-service-to-main - JHipster will import entities services here
|
||||
},
|
||||
template: '<App/>',
|
||||
});
|
||||
|
||||
initFortAwesome(app);
|
||||
|
||||
initBootstrapVue(app);
|
||||
|
||||
app.component('JhiItemCount', JhiItemCount).component('JhiSortIndicator', JhiSortIndicator).use(router).use(pinia).use(i18n).mount('#app');
|
||||
50
src/main/webapp/app/router/account.ts
Normal file
50
src/main/webapp/app/router/account.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Authority } from '@/shared/security/authority';
|
||||
|
||||
const Register = () => import('@/account/register/register.vue');
|
||||
const Activate = () => import('@/account/activate/activate.vue');
|
||||
const ResetPasswordInit = () => import('@/account/reset-password/init/reset-password-init.vue');
|
||||
const ResetPasswordFinish = () => import('@/account/reset-password/finish/reset-password-finish.vue');
|
||||
const ChangePassword = () => import('@/account/change-password/change-password.vue');
|
||||
const Settings = () => import('@/account/settings/settings.vue');
|
||||
const Sessions = () => import('@/account/sessions/sessions.vue');
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: Register,
|
||||
},
|
||||
{
|
||||
path: '/account/activate',
|
||||
name: 'Activate',
|
||||
component: Activate,
|
||||
},
|
||||
{
|
||||
path: '/account/reset/request',
|
||||
name: 'ResetPasswordInit',
|
||||
component: ResetPasswordInit,
|
||||
},
|
||||
{
|
||||
path: '/account/reset/finish',
|
||||
name: 'ResetPasswordFinish',
|
||||
component: ResetPasswordFinish,
|
||||
},
|
||||
{
|
||||
path: '/account/password',
|
||||
name: 'ChangePassword',
|
||||
component: ChangePassword,
|
||||
meta: { authorities: [Authority.USER] },
|
||||
},
|
||||
{
|
||||
path: '/account/sessions',
|
||||
name: 'Sessions',
|
||||
component: Sessions,
|
||||
meta: { authorities: [Authority.USER] },
|
||||
},
|
||||
{
|
||||
path: '/account/settings',
|
||||
name: 'Settings',
|
||||
component: Settings,
|
||||
meta: { authorities: [Authority.USER] },
|
||||
},
|
||||
];
|
||||
67
src/main/webapp/app/router/admin.ts
Normal file
67
src/main/webapp/app/router/admin.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Authority } from '@/shared/security/authority';
|
||||
|
||||
const JhiUserManagementComponent = () => import('@/admin/user-management/user-management.vue');
|
||||
const JhiUserManagementViewComponent = () => import('@/admin/user-management/user-management-view.vue');
|
||||
const JhiUserManagementEditComponent = () => import('@/admin/user-management/user-management-edit.vue');
|
||||
const JhiDocsComponent = () => import('@/admin/docs/docs.vue');
|
||||
const JhiConfigurationComponent = () => import('@/admin/configuration/configuration.vue');
|
||||
const JhiHealthComponent = () => import('@/admin/health/health.vue');
|
||||
const JhiLogsComponent = () => import('@/admin/logs/logs.vue');
|
||||
const JhiMetricsComponent = () => import('@/admin/metrics/metrics.vue');
|
||||
|
||||
export default [
|
||||
{
|
||||
path: '/admin/user-management',
|
||||
name: 'JhiUser',
|
||||
component: JhiUserManagementComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/user-management/new',
|
||||
name: 'JhiUserCreate',
|
||||
component: JhiUserManagementEditComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/user-management/:userId/edit',
|
||||
name: 'JhiUserEdit',
|
||||
component: JhiUserManagementEditComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/user-management/:userId/view',
|
||||
name: 'JhiUserView',
|
||||
component: JhiUserManagementViewComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/docs',
|
||||
name: 'JhiDocsComponent',
|
||||
component: JhiDocsComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/health',
|
||||
name: 'JhiHealthComponent',
|
||||
component: JhiHealthComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/logs',
|
||||
name: 'JhiLogsComponent',
|
||||
component: JhiLogsComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/metrics',
|
||||
name: 'JhiMetricsComponent',
|
||||
component: JhiMetricsComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
{
|
||||
path: '/admin/configuration',
|
||||
name: 'JhiConfigurationComponent',
|
||||
component: JhiConfigurationComponent,
|
||||
meta: { authorities: [Authority.ADMIN] },
|
||||
},
|
||||
];
|
||||
11
src/main/webapp/app/router/entities.ts
Normal file
11
src/main/webapp/app/router/entities.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
const Entities = () => import('@/entities/entities.vue');
|
||||
|
||||
// jhipster-needle-add-entity-to-router-import - JHipster will import entities to the router here
|
||||
|
||||
export default {
|
||||
path: '/',
|
||||
component: Entities,
|
||||
children: [
|
||||
// jhipster-needle-add-entity-to-router - JHipster will add entities to the router here
|
||||
],
|
||||
};
|
||||
48
src/main/webapp/app/router/index.ts
Normal file
48
src/main/webapp/app/router/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createRouter as createVueRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const Home = () => import('@/core/home/home.vue');
|
||||
const Error = () => import('@/core/error/error.vue');
|
||||
import account from '@/router/account';
|
||||
import admin from '@/router/admin';
|
||||
import entities from '@/router/entities';
|
||||
import pages from '@/router/pages';
|
||||
|
||||
export const createRouter = () =>
|
||||
createVueRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/forbidden',
|
||||
name: 'Forbidden',
|
||||
component: Error,
|
||||
meta: { error403: true },
|
||||
},
|
||||
{
|
||||
path: '/not-found',
|
||||
name: 'NotFound',
|
||||
component: Error,
|
||||
meta: { error404: true },
|
||||
},
|
||||
...account,
|
||||
...admin,
|
||||
entities,
|
||||
...pages,
|
||||
],
|
||||
});
|
||||
|
||||
const router = createRouter();
|
||||
|
||||
router.beforeResolve(async (to, from, next) => {
|
||||
if (!to.matched.length) {
|
||||
next({ path: '/not-found' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
5
src/main/webapp/app/router/pages.ts
Normal file
5
src/main/webapp/app/router/pages.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// jhipster-needle-add-entity-to-router-import - JHipster will import entities to the router here
|
||||
|
||||
export default [
|
||||
// jhipster-needle-add-entity-to-router - JHipster will add entities to the router here
|
||||
];
|
||||
191
src/main/webapp/app/shared/alert/alert.service.spec.ts
Normal file
191
src/main/webapp/app/shared/alert/alert.service.spec.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, vitest } from 'vitest';
|
||||
|
||||
import AlertService from './alert.service';
|
||||
|
||||
describe('Alert Service test suite', () => {
|
||||
let translationStub: vitest.Mock;
|
||||
let toastStub: vitest.Mock;
|
||||
let alertService: AlertService;
|
||||
|
||||
beforeEach(() => {
|
||||
translationStub = vitest.fn();
|
||||
toastStub = vitest.fn();
|
||||
alertService = new AlertService({
|
||||
i18n: { t: translationStub } as any,
|
||||
toast: {
|
||||
show: toastStub,
|
||||
} as any,
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast with translation/message', () => {
|
||||
const message = 'translatedMessage';
|
||||
|
||||
// WHEN
|
||||
alertService.showError(message);
|
||||
|
||||
// THEN
|
||||
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
|
||||
props: {
|
||||
body: message,
|
||||
pos: 'top-center',
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
solid: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show not reachable toast when http status = 0', () => {
|
||||
const translationKey = 'error.server.not.reachable';
|
||||
const message = 'Server not reachable';
|
||||
const httpErrorResponse = {
|
||||
status: 0,
|
||||
};
|
||||
// GIVEN
|
||||
translationStub.mockReturnValueOnce(message);
|
||||
|
||||
// WHEN
|
||||
alertService.showHttpError(httpErrorResponse);
|
||||
|
||||
// THEN
|
||||
expect(translationStub).toHaveBeenCalledExactlyOnceWith(translationKey);
|
||||
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
|
||||
props: {
|
||||
body: expect.any(String),
|
||||
pos: 'top-center',
|
||||
solid: true,
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show parameterized error toast when http status = 400 and entity headers', () => {
|
||||
const translationKey = 'error.update';
|
||||
const message = 'Updation Error';
|
||||
const httpErrorResponse = {
|
||||
status: 400,
|
||||
headers: {
|
||||
'x-jhipsterapp-error': translationKey,
|
||||
'x-jhipsterapp-params': 'dummyEntity',
|
||||
},
|
||||
};
|
||||
// GIVEN
|
||||
translationStub.mockImplementation(key => {
|
||||
if (key === translationKey) {
|
||||
return message;
|
||||
}
|
||||
if (key === 'global.menu.entities.dummyEntity') {
|
||||
return 'DummyEntity';
|
||||
}
|
||||
throw new Error();
|
||||
});
|
||||
|
||||
// WHEN
|
||||
alertService.showHttpError(httpErrorResponse);
|
||||
|
||||
// THEN
|
||||
expect(translationStub).toHaveBeenCalledTimes(2);
|
||||
expect(translationStub).toHaveBeenCalledWith(translationKey, { entityName: 'DummyEntity' });
|
||||
expect(translationStub).toHaveBeenCalledWith('global.menu.entities.dummyEntity');
|
||||
expect(toastStub).toHaveBeenCalledWith({
|
||||
props: {
|
||||
body: expect.any(String),
|
||||
pos: 'top-center',
|
||||
solid: true,
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast with data.message when http status = 400 and entity headers', () => {
|
||||
const message = 'Validation error';
|
||||
const httpErrorResponse = {
|
||||
status: 400,
|
||||
headers: {
|
||||
'x-jhipsterapp-error400': 'error',
|
||||
'x-jhipsterapp-params400': 'dummyEntity',
|
||||
},
|
||||
data: {
|
||||
message,
|
||||
fieldErrors: {
|
||||
field1: 'error1',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// GIVEN
|
||||
translationStub.mockReturnValueOnce(message);
|
||||
|
||||
// WHEN
|
||||
alertService.showHttpError(httpErrorResponse);
|
||||
|
||||
// THEN
|
||||
expect(translationStub).toHaveBeenCalledExactlyOnceWith(message);
|
||||
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
|
||||
props: {
|
||||
body: expect.any(String),
|
||||
pos: 'top-center',
|
||||
solid: true,
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast when http status = 404', () => {
|
||||
const translationKey = 'error.http.404';
|
||||
const message = 'The page does not exist.';
|
||||
const httpErrorResponse = {
|
||||
status: 404,
|
||||
};
|
||||
|
||||
// GIVEN
|
||||
translationStub.mockReturnValueOnce(message);
|
||||
|
||||
// WHEN
|
||||
alertService.showHttpError(httpErrorResponse);
|
||||
|
||||
// THEN
|
||||
expect(translationStub).toHaveBeenCalledExactlyOnceWith(translationKey);
|
||||
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
|
||||
props: {
|
||||
body: expect.any(String),
|
||||
pos: 'top-center',
|
||||
solid: true,
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast when http status != 400,404', () => {
|
||||
const message = 'Error 500';
|
||||
const httpErrorResponse = {
|
||||
status: 500,
|
||||
data: {
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
// GIVEN
|
||||
translationStub.mockReturnValueOnce(message);
|
||||
|
||||
// WHEN
|
||||
alertService.showHttpError(httpErrorResponse);
|
||||
|
||||
// THEN
|
||||
expect(translationStub).toHaveBeenCalledExactlyOnceWith(message);
|
||||
expect(toastStub).toHaveBeenCalledExactlyOnceWith({
|
||||
props: {
|
||||
body: expect.any(String),
|
||||
pos: 'top-center',
|
||||
solid: true,
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
87
src/main/webapp/app/shared/alert/alert.service.ts
Normal file
87
src/main/webapp/app/shared/alert/alert.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { type Composer, useI18n } from 'vue-i18n';
|
||||
|
||||
import { type BToastProps, useToast } from 'bootstrap-vue-next';
|
||||
|
||||
export const useAlertService = () => {
|
||||
const toast = useToast();
|
||||
if (!toast) {
|
||||
throw new Error('BootstrapVue toast component was not found');
|
||||
}
|
||||
const i18n = useI18n();
|
||||
return new AlertService({
|
||||
toast,
|
||||
i18n,
|
||||
});
|
||||
};
|
||||
|
||||
export default class AlertService {
|
||||
private toast: ReturnType<typeof useToast>;
|
||||
private i18n: Composer;
|
||||
|
||||
constructor({ toast, i18n }: { toast: ReturnType<typeof useToast>; i18n: Composer }) {
|
||||
this.toast = toast;
|
||||
this.i18n = i18n;
|
||||
}
|
||||
|
||||
showInfo(toastMessage: string, props: BToastProps = {}) {
|
||||
this.toast.show!({
|
||||
props: {
|
||||
pos: 'top-center',
|
||||
title: 'Info',
|
||||
variant: 'info',
|
||||
solid: true,
|
||||
body: toastMessage,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
showSuccess(toastMessage: string) {
|
||||
this.showInfo(toastMessage, {
|
||||
title: 'Success',
|
||||
variant: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
showError(toastMessage: string) {
|
||||
this.showInfo(toastMessage, {
|
||||
title: 'Error',
|
||||
variant: 'danger',
|
||||
});
|
||||
}
|
||||
|
||||
showHttpError(httpErrorResponse: any) {
|
||||
let errorMessage: string | null = null;
|
||||
switch (httpErrorResponse.status) {
|
||||
case 0:
|
||||
errorMessage = this.i18n.t('error.server.not.reachable').toString();
|
||||
break;
|
||||
|
||||
case 400: {
|
||||
const arr = Object.keys(httpErrorResponse.headers);
|
||||
let entityKey: string | null = null;
|
||||
for (const entry of arr) {
|
||||
if (entry.toLowerCase().endsWith('app-error')) {
|
||||
errorMessage = httpErrorResponse.headers[entry];
|
||||
} else if (entry.toLowerCase().endsWith('app-params')) {
|
||||
entityKey = httpErrorResponse.headers[entry];
|
||||
}
|
||||
}
|
||||
if (errorMessage && entityKey) {
|
||||
errorMessage = this.i18n.t(errorMessage, { entityName: this.i18n.t(`global.menu.entities.${entityKey}`) }).toString();
|
||||
} else if (!errorMessage) {
|
||||
errorMessage = this.i18n.t(httpErrorResponse.data.message).toString();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 404:
|
||||
errorMessage = this.i18n.t('error.http.404').toString();
|
||||
break;
|
||||
|
||||
default:
|
||||
errorMessage = this.i18n.t(httpErrorResponse.data.message).toString();
|
||||
}
|
||||
this.showError(errorMessage);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user