Skip to content

Commit

Permalink
Add settings page to delete user (#182)
Browse files Browse the repository at this point in the history
  • Loading branch information
FelixTJDietrich authored Nov 20, 2024
1 parent 1222ea7 commit 4131054
Show file tree
Hide file tree
Showing 14 changed files with 2,696 additions and 2,116 deletions.
4,456 changes: 2,382 additions & 2,074 deletions server/application-server/keycloak-hephaestus-realm-example-config.json

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions server/application-server/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AuthUserInfo"
/user:
delete:
tags:
- user
operationId: deleteUser
responses:
"200":
description: OK
components:
schemas:
PullRequestInfo:
Expand Down
5 changes: 5 additions & 0 deletions server/application-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-admin-client</artifactId>
<version>26.0.3</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,63 +44,99 @@ public OpenApiCustomizer schemaCustomizer() {
return openApi -> {
var components = openApi.getComponents();

// Only include schemas with DTO suffix and remove the suffix
var schemas = components
.getSchemas()
.entrySet()
.stream()
.filter(entry -> entry.getKey().endsWith("DTO"))
.collect(Collectors.toMap(entry -> entry.getKey().substring(0, entry.getKey().length() - 3),
if (components != null && components.getSchemas() != null) {
// Only include schemas with DTO suffix and remove the suffix
var schemas = components
.getSchemas()
.entrySet()
.stream()
.filter(entry -> entry.getKey().endsWith("DTO"))
.collect(Collectors.toMap(
entry -> entry.getKey().substring(0, entry.getKey().length() - 3),
entry -> {
var schema = entry.getValue();
schema.setName(entry.getKey().substring(0, entry.getKey().length() - 3));
return schema;
}));
}
));

// Remove DTO suffix from attribute names
schemas.forEach((key, value) -> {
Map<String, Schema<?>> properties = value.getProperties();
if (properties != null) {
properties.forEach((propertyKey, propertyValue) -> {
removeDTOSuffixesFromSchemaRecursively(propertyValue);
});
}
});
// Remove DTO suffix from attribute names
schemas.forEach((key, value) -> {
Map<String, Schema<?>> properties = value.getProperties();
if (properties != null) {
properties.forEach((propertyKey, propertyValue) -> {
removeDTOSuffixesFromSchemaRecursively(propertyValue);
});
}
});

components.setSchemas(schemas);
components.setSchemas(schemas);
} else {
logger.warn("Components or Schemas are null in OpenAPI configuration.");
}

var paths = openApi.getPaths();
paths.forEach((path, pathItem) -> {
logger.info(path);
pathItem.readOperations().forEach(operation -> {
// Remove DTO suffix from reponse schemas
var responses = operation.getResponses();
responses.forEach((responseCode, response) -> {
var content = response.getContent();
content.forEach((contentType, mediaType) -> {
removeDTOSuffixesFromSchemaRecursively(mediaType.getSchema());
if (paths != null) {
paths.forEach((path, pathItem) -> {
logger.info("Processing path: {}", path);
pathItem.readOperations().forEach(operation -> {
// Remove DTO suffix from response schemas
var responses = operation.getResponses();
if (responses != null) {
responses.forEach((responseCode, response) -> {
var content = response.getContent();
if (content != null) {
content.forEach((contentType, mediaType) -> {
if (mediaType != null && mediaType.getSchema() != null) {
removeDTOSuffixesFromSchemaRecursively(mediaType.getSchema());
} else {
logger.warn("MediaType or Schema is null for content type: {}", contentType);
}
});
} else {
logger.warn("Response with code {} has no content.", responseCode);
}
});
}

});
// Remove -controller suffix from tags
if (operation.getTags() != null) {
operation.setTags(
operation.getTags()
.stream()
.filter(tag -> {
if (tag.length() > 11) {
return true;
} else {
logger.warn("Tag '{}' is shorter than expected and cannot be trimmed.", tag);
return false;
}
})
.map(tag -> tag.substring(0, tag.length() - 11))
.collect(Collectors.toList())
);
}
});

// Remove -controller suffix from tags
operation.setTags(operation.getTags()
.stream()
.map(tag -> tag.substring(0, tag.length() - 11)).toList());
});
});


} else {
logger.warn("Paths are null in OpenAPI configuration.");
}
};
}

private void removeDTOSuffixesFromSchemaRecursively(Schema<?> schema) {
if (schema == null) {
return;
}

if (schema.get$ref() != null && schema.get$ref().endsWith("DTO")) {
schema.set$ref(schema.get$ref().substring(0, schema.get$ref().length() - 3));
String newRef = schema.get$ref().substring(0, schema.get$ref().length() - 3);
schema.set$ref(newRef);
logger.debug("Updated $ref from {} to {}", schema.get$ref(), newRef);
}

if (schema.getItems() != null) {
removeDTOSuffixesFromSchemaRecursively(schema.getItems());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package de.tum.in.www1.hephaestus.config;
import org.keycloak.OAuth2Constants;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.KeycloakBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.beans.factory.annotation.Value;


@Configuration
public class KeycloakConfig {

@Value("${keycloak.url}")
private String authServerUrl;

@Value("${keycloak.realm}")
private String realm;

@Value("${keycloak.client-id}")
private String clientId;

@Value("${keycloak.client-secret}")
private String clientSecret;

@Bean
public Keycloak keycloakClient() {
return KeycloakBuilder.builder()
.serverUrl(authServerUrl)
.realm(realm)
.grantType(OAuth2Constants.CLIENT_CREDENTIALS)
.clientId(clientId)
.clientSecret(clientSecret)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package de.tum.in.www1.hephaestus.gitprovider.user;

import java.util.Optional;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.keycloak.admin.client.Keycloak;
import org.keycloak.admin.client.resource.UsersResource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -11,7 +19,16 @@
@RestController
@RequestMapping("/user")
public class UserController {
private final UserService userService;

private static final Logger logger = LoggerFactory.getLogger(UserController.class);

@Autowired
private UserService userService;
@Autowired
private Keycloak keycloak;
@Value("${keycloak.realm}")
private String realm;


public UserController(UserService actorService) {
this.userService = actorService;
Expand All @@ -22,4 +39,21 @@ public ResponseEntity<UserProfileDTO> getUserProfile(@PathVariable String login)
Optional<UserProfileDTO> userProfile = userService.getUserProfile(login);
return userProfile.map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build());
}

@DeleteMapping
public ResponseEntity<Void> deleteUser(JwtAuthenticationToken auth) {
if (auth == null) {
logger.error("No authentication token found.");
return ResponseEntity.badRequest().body(null);
}

String userId = auth.getToken().getClaimAsString(StandardClaimNames.SUB);
logger.info("Deleting user {}", userId);
var response = keycloak.realm(realm).users().delete(userId);
if (response.getStatus() != 204) {
logger.error("Failed to delete user account: {}", response.getStatusInfo().getReasonPhrase());
return ResponseEntity.badRequest().body(null);
}
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ hephaestus:
enabled: ${LEADERBOARD_NOTIFICATION_ENABLED:true}
channel-id: ${LEADERBOARD_NOTIFICATION_CHANNEL_ID:G6TCVL6HL}

keycloak:
url: ${KEYCLOAK_URL}
realm: ${KEYCLOAK_REALM}
client-id: ${KEYCLOAK_CLIENT_ID}
client-secret: ${KEYCLOAK_CLIENT_SECRET}


nats:
enabled: ${NATS_ENABLED:false}
timeframe: ${MONITORING_TIMEFRAME:7}
Expand Down
5 changes: 5 additions & 0 deletions server/application-server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ hephaestus:
enabled: false
channel-id: ""

keycloak:
url: http://localhost:8081
realm: hephaestus
client-id: hephaestus-confidential
client-secret: 0g0QtFmz6GB1Jv03SszCFepPro0hiP7G

nats:
enabled: false
Expand Down
2 changes: 2 additions & 0 deletions webapp/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { HomeComponent } from '@app/home/home.component';
import { AdminComponent } from '@app/admin/admin.component';
import { AdminGuard } from '@app/core/security/admin.guard';
import { UserProfileComponent } from '@app/user/user-profile.component';
import { SettingsComponent } from '@app/settings/settings.component';
import { ImprintComponent } from '@app/legal/imprint/imprint.component';

export const routes: Routes = [
Expand All @@ -15,5 +16,6 @@ export const routes: Routes = [
canActivate: [AdminGuard]
},
{ path: 'user/:id', component: UserProfileComponent },
{ path: 'settings', component: SettingsComponent },
{ path: 'imprint', component: ImprintComponent }
];
4 changes: 4 additions & 0 deletions webapp/src/app/core/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
<hlm-icon name="lucideUser" hlmMenuIcon />
<span>My Profile</span>
</a>
<a hlmMenuItem routerLink="/settings" class="cursor-pointer">
<hlm-icon name="lucideSettings" hlmMenuIcon />
<span>Settings</span>
</a>
<hlm-menu-separator />
<button hlmMenuItem (click)="signOut()" class="cursor-pointer">
<hlm-icon name="lucideLogOut" hlmMenuIcon />
Expand Down
5 changes: 3 additions & 2 deletions webapp/src/app/core/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { SecurityStore } from '@app/core/security/security-store.service';
import { ThemeSwitcherComponent } from '@app/core/theme/theme-switcher.component';
import { RequestFeatureComponent } from './request-feature/request-feature.component';
import { environment } from 'environments/environment';
import { lucideUser, lucideLogOut } from '@ng-icons/lucide';
import { lucideUser, lucideLogOut, lucideSettings } from '@ng-icons/lucide';
import { provideIcons } from '@ng-icons/core';

@Component({
Expand All @@ -32,7 +32,8 @@ import { provideIcons } from '@ng-icons/core';
providers: [
provideIcons({
lucideUser,
lucideLogOut
lucideLogOut,
lucideSettings
})
]
})
Expand Down
58 changes: 58 additions & 0 deletions webapp/src/app/core/modules/openapi/api/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,64 @@ export class UserService implements UserServiceInterface {
return httpParams;
}

/**
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
*/
public deleteUser(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any>;
public deleteUser(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpResponse<any>>;
public deleteUser(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<HttpEvent<any>>;
public deleteUser(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: undefined, context?: HttpContext, transferCache?: boolean}): Observable<any> {

let localVarHeaders = this.defaultHeaders;

let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept;
if (localVarHttpHeaderAcceptSelected === undefined) {
// to determine the Accept header
const httpHeaderAccepts: string[] = [
];
localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts);
}
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}

let localVarHttpContext: HttpContext | undefined = options && options.context;
if (localVarHttpContext === undefined) {
localVarHttpContext = new HttpContext();
}

let localVarTransferCache: boolean | undefined = options && options.transferCache;
if (localVarTransferCache === undefined) {
localVarTransferCache = true;
}


let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}

let localVarPath = `/user`;
return this.httpClient.request<any>('delete', `${this.configuration.basePath}${localVarPath}`,
{
context: localVarHttpContext,
responseType: <any>responseType_,
withCredentials: this.configuration.withCredentials,
headers: localVarHeaders,
observe: observe,
transferCache: localVarTransferCache,
reportProgress: reportProgress
}
);
}

/**
* @param login
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
Expand Down
Loading

0 comments on commit 4131054

Please sign in to comment.