Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add settings page to delete user #182

Merged
merged 6 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading