Merge branch 'main' of git.avatic.de:avatic/lcc_tool

This commit is contained in:
Anja Guenther 2025-10-26 21:50:59 +01:00
commit 562764561e
23 changed files with 664 additions and 65 deletions

View file

@ -1,54 +1,50 @@
services: services:
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
container_name: lcc-mysql container_name: lcc-mysql-local
environment: environment:
MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_DATABASE} MYSQL_DATABASE: lcc
MYSQL_USER: ${DB_USER} MYSQL_USER: ${SPRING_DATASOURCE_USERNAME}
MYSQL_PASSWORD: ${DB_PASSWORD} MYSQL_PASSWORD: ${SPRING_DATASOURCE_PASSWORD}
volumes: volumes:
- mysql-data:/var/lib/mysql - mysql-data-local:/var/lib/mysql
ports: ports:
- "3306:3306" - "3306:3306"
networks: networks:
- lcc-network - lcc-network-local
healthcheck: healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped
lcc-app: lcc-app:
#image: git.avatic.de/avatic/lcc:latest #image: git.avatic.de/avatic/lcc:latest
# Oder für lokales Bauen:
build: . build: .
container_name: lcc-app container_name: lcc-app-local
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
env_file:
- .env
environment: environment:
DB_DATABASE: ${DB_DATABASE} # Überschreibe die Datasource URL für Docker-Netzwerk
DB_USER: ${DB_USER} SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/lcc
DB_PASSWORD: ${DB_PASSWORD}
ALLOWED_CORS_DOMAIN: ${ALLOWED_CORS_DOMAIN}
LCC_BASE_URL: ${LCC_BASE_URL}
AZURE_MAPS_CLIENT_ID: ${AZURE_MAPS_CLIENT_ID}
AZURE_MAPS_SUBSCRIPTION_KEY: ${AZURE_MAPS_SUBSCRIPTION_KEY}
AZURE_TENANT_ID: ${AZURE_TENANT_ID}
AZURE_CLIENT_ID: ${AZURE_CLIENT_ID}
AZURE_CLIENT_SECRET: ${AZURE_CLIENT_SECRET}
JWT_SECRET: ${JWT_SECRET}
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${DB_DATABASE}
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE}
ports: ports:
- "8080:8080" - "8080:8080"
networks: networks:
- lcc-network - lcc-network-local
dns:
- 8.8.8.8
- 8.8.4.4
restart: unless-stopped restart: unless-stopped
volumes: volumes:
mysql-data: mysql-data-local:
networks: networks:
lcc-network: lcc-network-local:
driver: bridge driver: bridge

View file

@ -0,0 +1,226 @@
<template>
<div class="stage-container">
<transition name="slide-fade" mode="out-in">
<div v-if="stage1" key="stage1">
<div class="add-app">
<div>App name</div>
<div>
<div class="text-container"><input class="input-field" v-model="appName" @input="checkChange"/></div>
</div>
<div class="add-app-group-header">App groups</div>
<div>
<app-group-item v-for="group in groups" :group-obj="group" :key="group.group_name"
:selected="isSelected(group.group_name)"
@checkbox-changed="updateSelected"></app-group-item>
</div>
<div></div>
</div>
<div class="add-app-footer">
<basic-button :show-icon="false" @click="addApp" :disabled="disableButton">Add app</basic-button>
<basic-button :show-icon="false" @click="closeModal(false)" variant="secondary">Cancel</basic-button>
</div>
</div>
<div v-else key="stage2">
<div class="secret-warning">
<ph-warning size="18px"></ph-warning>
This is your app's client secret. Please keep it safe. It will not be shown again.
</div>
<box variant="border" class="add-app-secret">
<div class="secret-header">App</div>
<div class="secret">{{ appName }}</div>
<div class="secret-header">Client Id</div>
<div class="secret">{{ clientId }}</div>
<div class="secret-header">Client secret</div>
<div class="secret">{{ clientSecret }}</div>
</box>
<div class="add-app-footer">
<basic-button :show-icon="false" @click="closeModal(true)">Close</basic-button>
</div>
</div>
</transition>
</div>
</template>
<script>
import InputField from "@/components/UI/InputField.vue";
import {mapStores} from "pinia";
import {useGroupStore} from "@/store/group.js";
import Checkbox from "@/components/UI/Checkbox.vue";
import BasicButton from "@/components/UI/BasicButton.vue";
import AppGroupItem from "@/components/UI/AppGroupItem.vue";
import {useAppsStore} from "@/store/apps.js";
import checkbox from "@/components/UI/Checkbox.vue";
import Box from "@/components/UI/Box.vue";
export default {
name: "AddApp",
components: {Box, AppGroupItem, BasicButton, Checkbox, InputField},
computed: {
checkbox() {
return checkbox
},
...mapStores(useGroupStore, useAppsStore),
groups() {
return this.groupStore.groups;
},
disableButton() {
return this.appName === null || this.appName.length === 0 || Object.values(this.selectedGroups).every(value => !value);
}
},
async created() {
await this.groupStore.loadGroups()
this.groupStore.groups?.forEach(group => {
this.selectedGroups[group.group_name] = false;
})
},
data() {
return {
stage1: true,
appName: '',
selectedGroups: {},
clientSecret: '',
clientId: '',
}
},
methods: {
isSelected(groupName) {
if (groupName === null || this.selectedGroups == null || Object.keys(this.selectedGroups).length === 0)
return false;
const group = this.selectedGroups[groupName];
if ((group ?? null) === null)
return false;
return group;
},
async addApp() {
if (!this.disableButton) {
const app = await this.appsStore.addApp(this.appName, this.selectedGroups);
this.stage1 = false;
console.log("app in add app", app);
this.clientSecret = app.client_secret;
this.clientId = app.client_id;
}
},
updateSelected(checked, groupName) {
this.selectedGroups[groupName] = checked;
},
closeModal() {
this.$emit('close');
}
}
}
</script>
<style scoped>
.stage-container {
position: relative;
min-height: 20rem;
margin: 1.6rem;
}
/* Transition animations */
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s ease-in;
}
.slide-fade-enter-from {
transform: translateX(20px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateX(-20px);
opacity: 0;
}
.add-app {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: 10px;
font-weight: 500;
font-size: 1.4rem;
align-items: center;
}
.add-app-group-header {
align-self: start;
padding-top: 2.4rem;
}
.text-container {
display: flex;
align-items: center;
background: white;
border-radius: 0.4rem;
padding: 0.6rem 1.2rem;
/* box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);*/
border: 0.2rem solid #E3EDFF;
transition: all 0.1s ease;
flex: 1 0 auto;
}
.input-field {
border: none;
outline: none;
background: none;
resize: none;
font-family: inherit;
font-size: 1.4rem;
color: #002F54;
width: 100%;
min-width: 5rem;
}
.add-app-footer {
display: flex;
justify-content: flex-end;
gap: 1.6rem;
}
.secret-warning {
display: flex;
align-items: center;
font-size: 1.4rem;
gap: 1.6rem;
background-color: #c3cfdf;
color: #002F54;
border-radius: 0.8rem;
padding: 1.6rem;
margin-bottom: 1.6rem;
}
.secret-header {
font-weight: 500;
font-size: 1.4rem;
color: #002F54;
}
.secret {
font-weight: 400;
font-size: 1.4rem;
color: #6b7280;
}
.add-app-secret {
display: grid;
grid-template-columns: auto 1fr;
grid-gap: 2.4rem;
font-weight: 500;
font-size: 1.4rem;
align-items: center;
margin-bottom: 2.4rem;
}
</style>

View file

@ -0,0 +1,60 @@
<template>
<div class="app-group-item">
<checkbox :checked="selected" @checkbox-changed="checkboxChanged"></checkbox>
<div>
<div class="app-group-item-name">{{ groupObj.group_name }}</div>
<div class="app-group-item-descr">{{ groupObj.group_description }}</div>
</div>
</div>
</template>
<script>
import Checkbox from "@/components/UI/Checkbox.vue";
export default {
name: "AppGroupItem",
components: {Checkbox},
props: {
groupObj: {
type: Object,
required: true
},
selected: {
type: Boolean,
required: true
}
},
methods: {
checkboxChanged(checked) {
this.$emit('checkbox-changed', checked, this.groupObj.group_name);
}
}
}
</script>
<style scoped>
.app-group-item {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.8rem;
padding: 1.6rem;
}
.app-group-item-name {
font-weight: 500;
font-size: 1.4rem;
}
.app-group-item-descr {
font-weight: 400;
font-size: 1.4rem;
color: #6b7280;
}
</style>

View file

@ -0,0 +1,83 @@
<template>
<div>
<div class="app-list-item">
<div class="app-name-container"><div class="app-name-name">{{ app.name }}</div><div class="app-name-id">{{ app.client_id}}</div></div>
<div class="badge-list"> <basic-badge variant="secondary" icon="lock" v-for="group in groups" :key="group">{{group}}</basic-badge></div>
<div class="action-container"> <icon-button icon="trash" @click="deleteClick"></icon-button></div>
</div>
</div>
</template>
<script>
import Box from "@/components/UI/Box.vue";
import IconButton from "@/components/UI/IconButton.vue";
import BasicBadge from "@/components/UI/BasicBadge.vue";
export default {
name: "AppListItem",
components: {BasicBadge, IconButton, Box},
emits: ["deleteApp"],
props: {
app: {
type: Object,
required: true
}
},
computed: {
groups() {
return this.app.groups;
}
},
methods: {
deleteClick() {
this.$emit("deleteApp", this.app.id);
}
}
}
</script>
<style scoped>
.app-list-item {
display: grid;
grid-template-columns: 1fr 2fr 0.5fr;
grid-gap: 2.4rem;
padding: 1.6rem 0;
font-weight: 400;
font-size: 1.4rem;
border-bottom: 0.1rem solid #E3EDFF;
align-items: center;
justify-items: start;
}
.app-name-container {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.badge-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 0.8rem;
}
.app-name-name {
font-weight: 500;
font-size: 1.4rem;
}
.app-name-id {
font-weight: 400;
font-size: 1.4rem;
color: #6b7280;
}
.action-container{
align-self: center;
}
</style>

View file

@ -0,0 +1,86 @@
<template>
<div class="app-list-header">
<div>App</div>
<div>Groups</div>
<div>Action</div>
</div>
<div class="app-list">
<app-list-item v-for="app in apps" :app="app" @delete-app="deleteApp"></app-list-item>
</div>
<modal :state="modalState">
<add-app @close="closeModal"></add-app>
</modal>
<basic-button icon="Plus" @click="modalState = true">New App</basic-button>
</template>
<script>
import BasicButton from "@/components/UI/BasicButton.vue";
import AppListItem from "@/components/UI/AppListItem.vue";
import {mapStores} from "pinia";
import {useAppsStore} from "@/store/apps.js";
import Modal from "@/components/UI/Modal.vue";
import AddApp from "@/components/UI/AddApp.vue";
export default {
name: "Apps",
props: {
isSelected: {
type: Boolean,
default: false
}
},
components: {AddApp, Modal, AppListItem, BasicButton},
computed: {
...mapStores(useAppsStore),
apps() {
return this.appsStore.apps;
}
},
data() {
return {
modalState: false
}
},
methods: {
async closeModal(success) {
this.modalState = false;
if (success) {
await this.appsStore.loadApps();
}
},
deleteApp(id) {
this.appsStore.deleteApp(id);
}
},
async created() {
await this.appsStore.loadApps();
}
}
</script>
<style scoped>
.app-list-header {
display: grid;
grid-template-columns: 1fr 2fr 0.5fr;
grid-gap: 1rem;
padding: 1rem;
margin-bottom: 1rem;
font-weight: 500;
font-size: 1.4rem;
color: #6B869C;
text-transform: uppercase;
letter-spacing: 0.08rem;
border-bottom: 0.1rem solid #E3EDFF;
}
.app-list {
margin-bottom: 2.4rem;
}
</style>

View file

@ -27,6 +27,7 @@ import Materials from "@/components/layout/config/Materials.vue";
import ErrorLog from "@/pages/ErrorLog.vue"; import ErrorLog from "@/pages/ErrorLog.vue";
import {mapStores} from "pinia"; import {mapStores} from "pinia";
import {useActiveUserStore} from "@/store/activeuser.js"; import {useActiveUserStore} from "@/store/activeuser.js";
import Apps from "@/components/layout/config/Apps.vue";
export default { export default {
name: "Config", name: "Config",
@ -39,6 +40,11 @@ export default {
component: markRaw(Properties), component: markRaw(Properties),
props: {isSelected: false}, props: {isSelected: false},
}, },
appsTab: {
title: 'Apps',
component: markRaw(Apps),
props: {isSelected: false},
},
systemLogTab: { systemLogTab: {
title: 'System log', title: 'System log',
component: markRaw(ErrorLog), component: markRaw(ErrorLog),
@ -76,6 +82,10 @@ export default {
tabs.push(this.systemLogTab); tabs.push(this.systemLogTab);
} }
if(this.activeUserStore.isService) {
tabs.push(this.appsTab);
}
tabs.push(this.materialsTab); tabs.push(this.materialsTab);
tabs.push(this.nodesTab); tabs.push(this.nodesTab);

View file

@ -18,7 +18,7 @@ export const useActiveUserStore = defineStore('activeUser', {
allowConfiguration(state) { allowConfiguration(state) {
if (state.user === null) if (state.user === null)
return false; return false;
return state.user.groups?.includes("super") || state.user.groups?.includes("freight") || state.user.groups?.includes("packaging"); return state.user.groups?.includes("super") || state.user.groups?.includes("freight") || state.user.groups?.includes("packaging") || state.user.groups?.includes("service");
}, },
allowReporting(state) { allowReporting(state) {
if (state.user === null) if (state.user === null)
@ -30,6 +30,11 @@ export const useActiveUserStore = defineStore('activeUser', {
return false; return false;
return state.user.groups?.includes("super"); return state.user.groups?.includes("super");
}, },
isService(state) {
if (state.user === null)
return false;
return state.user.groups?.includes("service");
},
isPackaging(state) { isPackaging(state) {
if (state.user === null) if (state.user === null)
return false; return false;

View file

@ -0,0 +1,72 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import {useErrorStore} from "@/store/error.js";
import performRequest from "@/backend.js";
import logger from "@/logger.js";
export const useAppsStore = defineStore('apps', {
state: () => ({
apps: [],
loading: false,
}),
getters: {
getById: (state) => {
return (id) => state.apps.find(p => p.id === id)
}
},
actions: {
async loadApps() {
this.loading = true;
const url = `${config.backendUrl}/apps`;
const resp = await performRequest(this, 'GET', url, null);
this.apps = resp.data;
this.loading = false;
},
async addApp(appName, appGroups) {
const url = `${config.backendUrl}/apps`;
const app = {
name: appName,
groups: [],
}
for (const [key, value] of Object.entries(appGroups)) {
if (value) {
app.groups.push(key);
}
}
const resp = await performRequest(this, 'POST', url, app);
this.apps.push(resp.data);
return resp.data;
},
async updateApp(appId, appName, appGroups) {
const url = `${config.backendUrl}/apps/${appId}`;
const app = {
id: appId,
name: appName,
groups: [],
};
for (const [key, value] of Object.entries(appGroups)) {
if (value) {
app.groups.push(key);
}
}
const resp = await performRequest(this, 'POST', url, app);
const index = this.apps.findIndex(a => a.id === app.id);
this.apps[index] = resp.data;
},
async deleteApp(appId) {
const url = `${config.backendUrl}/apps/${appId}`;
await performRequest(this, 'DELETE', url, null, false);
this.apps = this.apps.filter(a => a.id !== appId);
}
}
});

View file

@ -0,0 +1,25 @@
import {defineStore} from 'pinia'
import {config} from '@/config'
import performRequest from "@/backend.js";
export const useGroupStore = defineStore('group', {
state: () => ({
groups: [],
loading: false,
}),
getters: {
getById: (state) => {
return (id) => state.group.find(p => p.id === id)
}
},
actions: {
async loadGroups() {
this.loading = true;
const url = `${config.backendUrl}/groups`;
const resp = await performRequest(this,'GET', url, null);
this.groups = resp.data;
this.loading = false;
}
}
});

View file

@ -20,12 +20,15 @@ import java.util.Arrays;
@Profile("dev | test") @Profile("dev | test")
public class CorsConfig implements WebMvcConfigurer { public class CorsConfig implements WebMvcConfigurer {
@Autowired private final Environment environment;
private Environment environment;
@Value("${lcc.allowed_cors}") @Value("${lcc.allowed_cors}")
private String allowedCors; private String allowedCors;
public CorsConfig(Environment environment) {
this.environment = environment;
}
@Override @Override
public void addCorsMappings(@NotNull CorsRegistry registry) { public void addCorsMappings(@NotNull CorsRegistry registry) {
String[] activeProfiles = environment.getActiveProfiles(); String[] activeProfiles = environment.getActiveProfiles();

View file

@ -23,11 +23,11 @@ public class LccOidcUser extends DefaultOidcUser {
this.userId = userId; this.userId = userId;
} }
public static User createDatabaseUser(String email, String firstName, String lastName, String workdayId) { public static User createDatabaseUser(String email, String firstName, String lastName, String workdayId, boolean isFirstUser) {
User user = new User(); User user = new User();
Group group = new Group(); Group group = new Group();
group.setName("none"); group.setName(isFirstUser ? "service" : "none");
user.setEmail(email); user.setEmail(email);
user.setFirstName(firstName == null ? "" : firstName); user.setFirstName(firstName == null ? "" : firstName);

View file

@ -2,7 +2,7 @@ package de.avatic.lcc.config;
import de.avatic.lcc.model.db.users.User; import de.avatic.lcc.model.db.users.User;
import de.avatic.lcc.repositories.users.GroupRepository; import de.avatic.lcc.repositories.users.GroupRepository;
import de.avatic.lcc.repositories.users.JwtTokenService; import de.avatic.lcc.service.apps.JwtTokenService;
import de.avatic.lcc.repositories.users.UserRepository; import de.avatic.lcc.repositories.users.UserRepository;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
@ -48,9 +48,6 @@ import java.util.function.Supplier;
@EnableMethodSecurity @EnableMethodSecurity
public class SecurityConfig { public class SecurityConfig {
@Value("${lcc.base.url}")
private String baseUrl;
@Bean @Bean
@Profile("!dev & !test") // Only active when NOT in dev profile @Profile("!dev & !test") // Only active when NOT in dev profile
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtTokenService jwtTokenService) throws Exception {
@ -63,10 +60,6 @@ public class SecurityConfig {
) )
.oauth2Login(oauth2 -> oauth2 .oauth2Login(oauth2 -> oauth2
.defaultSuccessUrl("/", true) .defaultSuccessUrl("/", true)
// Redirect-URI explizit setzen:
// .redirectionEndpoint(redirection -> redirection
// .baseUri(baseUrl + "/login/oauth2/code/*")
// )
) )
.oauth2ResourceServer(oauth2 -> oauth2 .oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt .jwt(jwt -> jwt
@ -178,8 +171,9 @@ public class SecurityConfig {
} }
if (user == null) { if (user == null) {
userRepository.update(LccOidcUser.createDatabaseUser(email, oidcUser.getGivenName(), oidcUser.getFamilyName(), workdayId)); var isFirstUser = userRepository.count() == 0;
mappedAuthorities.add(new SimpleGrantedAuthority("ROLE_NONE")); userRepository.update(LccOidcUser.createDatabaseUser(email, oidcUser.getGivenName(), oidcUser.getFamilyName(), workdayId, isFirstUser));
mappedAuthorities.add(new SimpleGrantedAuthority(isFirstUser ? "ROLE_SERVICE" : "ROLE_NONE"));
} }

View file

@ -1,5 +1,5 @@
package de.avatic.lcc.config; package de.avatic.lcc.config;
import de.avatic.lcc.repositories.users.JwtTokenService; import de.avatic.lcc.service.apps.JwtTokenService;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;

View file

@ -1,13 +1,10 @@
package de.avatic.lcc.dto.configuration.apps; package de.avatic.lcc.controller.configuration;
import de.avatic.lcc.dto.users.AppDTO; import com.azure.core.annotation.BodyParam;
import de.avatic.lcc.repositories.users.AppRepository; import de.avatic.lcc.dto.configuration.apps.AppDTO;
import de.avatic.lcc.service.apps.AppsService; import de.avatic.lcc.service.apps.AppsService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@ -29,9 +26,15 @@ public class AppsController {
} }
@PostMapping({"", "/"}) @PostMapping({"", "/"})
public ResponseEntity<AppDTO> updateApp(AppDTO dto) { public ResponseEntity<AppDTO> updateApp(@RequestBody AppDTO dto) {
return ResponseEntity.ok(appsService.updateApp(dto)); return ResponseEntity.ok(appsService.updateApp(dto));
} }
@DeleteMapping({"/{id}", "/{id}/"})
public ResponseEntity<Void> deleteApp(@PathVariable Integer id) {
appsService.deleteApp(id);
return ResponseEntity.ok().build();
}
} }

View file

@ -1,7 +1,7 @@
package de.avatic.lcc.controller.token; package de.avatic.lcc.controller.token;
import de.avatic.lcc.model.db.users.App; import de.avatic.lcc.model.db.users.App;
import de.avatic.lcc.repositories.users.JwtTokenService; import de.avatic.lcc.service.apps.JwtTokenService;
import de.avatic.lcc.service.apps.AppsService; import de.avatic.lcc.service.apps.AppsService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;

View file

@ -33,7 +33,7 @@ public class GroupController {
* @return A ResponseEntity containing the list of groups and pagination headers. * @return A ResponseEntity containing the list of groups and pagination headers.
*/ */
@GetMapping({"/", ""}) @GetMapping({"/", ""})
@PreAuthorize("hasRole('RIGHT-MANAGMENT')") @PreAuthorize("hasAnyRole('RIGHT-MANAGMENT', 'SERVICE')")
public ResponseEntity<List<GroupDTO>> listGroups(@RequestParam(defaultValue = "20") @Min(1) int limit, public ResponseEntity<List<GroupDTO>> listGroups(@RequestParam(defaultValue = "20") @Min(1) int limit,
@RequestParam(defaultValue = "1") @Min(1) int page) { @RequestParam(defaultValue = "1") @Min(1) int page) {

View file

@ -1,4 +1,4 @@
package de.avatic.lcc.dto.users; package de.avatic.lcc.dto.configuration.apps;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;

View file

@ -86,11 +86,14 @@ public class AppRepository {
List<Integer> groupIds = groupRepository.findGroupIds(app.getGroups().stream().map(Group::getName).toList()); List<Integer> groupIds = groupRepository.findGroupIds(app.getGroups().stream().map(Group::getName).toList());
if (appId == null) { if (appId == null) {
String sql = """
INSERT INTO sys_app (name, client_id, client_secret)
VALUES (?, ?, ?)""";
KeyHolder keyHolder = new GeneratedKeyHolder(); KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement( PreparedStatement ps = connection.prepareStatement(
"INSERT INTO sys_app (name, client_id, client_secret) " + sql,
"VALUES (?, ?, ?)",
Statement.RETURN_GENERATED_KEYS Statement.RETURN_GENERATED_KEYS
); );
ps.setString(1, app.getName()); ps.setString(1, app.getName());
@ -104,7 +107,7 @@ public class AppRepository {
String query = """ String query = """
UPDATE sys_app SET name = ? WHERE id = ?"""; UPDATE sys_app SET name = ? WHERE id = ?""";
jdbcTemplate.update(query, app.getName()); jdbcTemplate.update(query, app.getName(), appId);
} }
updateAppGroupMappings(appId, groupIds); updateAppGroupMappings(appId, groupIds);
@ -150,13 +153,19 @@ public class AppRepository {
/** /**
* Deletes an app by id. * Deletes an app by id.
* Also removes all associated group mappings.
* *
* @param id id of the app to delete * @param id id of the app to delete
*/ */
@Transactional @Transactional
public void delete(Integer id) { public void delete(Integer id) {
String sql = "DELETE FROM sys_app WHERE id = ?"; // First delete all group mappings for this app
jdbcTemplate.update(sql, id); String deleteMappingsSql = "DELETE FROM sys_app_group_mapping WHERE app_id = ?";
jdbcTemplate.update(deleteMappingsSql, id);
// Then delete the app itself
String deleteAppSql = "DELETE FROM sys_app WHERE id = ?";
jdbcTemplate.update(deleteAppSql, id);
} }
/** /**

View file

@ -96,8 +96,10 @@ public class UserRepository {
} }
@Transactional
public Integer count() {
return jdbcTemplate.queryForObject("SELECT COUNT(*) FROM sys_user", Integer.class);
}
private void updateUserGroupMappings(Integer userId, List<Integer> groups) { private void updateUserGroupMappings(Integer userId, List<Integer> groups) {

View file

@ -1,14 +1,17 @@
package de.avatic.lcc.service.apps; package de.avatic.lcc.service.apps;
import de.avatic.lcc.dto.users.AppDTO; import de.avatic.lcc.dto.configuration.apps.AppDTO;
import de.avatic.lcc.model.db.users.App; import de.avatic.lcc.model.db.users.App;
import de.avatic.lcc.repositories.users.AppRepository; import de.avatic.lcc.repositories.users.AppRepository;
import de.avatic.lcc.service.transformer.apps.AppTransformer; import de.avatic.lcc.service.transformer.apps.AppTransformer;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID;
@Service @Service
public class AppsService { public class AppsService {
@ -30,11 +33,35 @@ public class AppsService {
public AppDTO updateApp(AppDTO dto) { public AppDTO updateApp(AppDTO dto) {
var newApp = dto.getId() == null; var newApp = dto.getId() == null;
var id = !newApp ? dto.getId() : appRepository.update(appTransformer.toAppEntity(dto)); String appSecret = null;
if(newApp) {
dto.setClientId(generateAppId());
appSecret = generateAppSecret();
dto.setClientSecret(passwordEncoder.encode(appSecret));
}
var id = appRepository.update(appTransformer.toAppEntity(dto));
if(newApp) {
dto.setId(id);
dto.setClientSecret(appSecret);
}
return dto; return dto;
} }
private String generateAppId() {
return UUID.randomUUID().toString();
}
private String generateAppSecret() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public void deleteApp(Integer id) { public void deleteApp(Integer id) {
appRepository.delete(id); appRepository.delete(id);
} }

View file

@ -1,4 +1,4 @@
package de.avatic.lcc.repositories.users; package de.avatic.lcc.service.apps;
import de.avatic.lcc.model.db.users.App; import de.avatic.lcc.model.db.users.App;
import de.avatic.lcc.model.db.users.Group; import de.avatic.lcc.model.db.users.Group;
@ -12,7 +12,6 @@ import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.Key; import java.security.Key;
import java.util.Date; import java.util.Date;
import java.util.List;
@Service @Service
public class JwtTokenService { public class JwtTokenService {
@ -28,7 +27,6 @@ public class JwtTokenService {
public String createApplicationToken(App app, long expiration) { public String createApplicationToken(App app, long expiration) {
return Jwts.builder() return Jwts.builder()
.issuer(baseUrl) .issuer(baseUrl)
.subject(app.getClientId()) .subject(app.getClientId())

View file

@ -1,6 +1,6 @@
package de.avatic.lcc.service.transformer.apps; package de.avatic.lcc.service.transformer.apps;
import de.avatic.lcc.dto.users.AppDTO; import de.avatic.lcc.dto.configuration.apps.AppDTO;
import de.avatic.lcc.model.db.users.App; import de.avatic.lcc.model.db.users.App;
import de.avatic.lcc.model.db.users.Group; import de.avatic.lcc.model.db.users.Group;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View file

@ -137,7 +137,7 @@ CREATE TABLE IF NOT EXISTS `sys_user_node`
FOREIGN KEY (`country_id`) REFERENCES `country` (`id`) FOREIGN KEY (`country_id`) REFERENCES `country` (`id`)
) COMMENT 'Contains user generated logistic nodes'; ) COMMENT 'Contains user generated logistic nodes';
-- Main table for user information -- Main table for app information
CREATE TABLE IF NOT EXISTS `sys_app` CREATE TABLE IF NOT EXISTS `sys_app`
( (
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
@ -147,7 +147,7 @@ CREATE TABLE IF NOT EXISTS `sys_app`
) COMMENT 'Stores basic information about external applications'; ) COMMENT 'Stores basic information about external applications';
-- Junction table for user-group assignments -- Junction table for app-group assignments
CREATE TABLE IF NOT EXISTS `sys_app_group_mapping` CREATE TABLE IF NOT EXISTS `sys_app_group_mapping`
( (
`app_id` INT NOT NULL, `app_id` INT NOT NULL,