Skip to content

Commit 3ebcfb7

Browse files
committed
Profile
1 parent 01fe205 commit 3ebcfb7

File tree

9 files changed

+237
-5
lines changed

9 files changed

+237
-5
lines changed

client/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import ExternalApplication from "./pages/ExternalApplication.jsx";
3939
import Feedback from "./pages/Feedback.jsx";
4040
import MyOrganization from "./pages/MyOrganization.jsx";
4141
import ApplicationOverview from "./pages/ApplicationOverview.jsx";
42+
import Profile from "./pages/Profile.jsx";
4243

4344
const App = () => {
4445

@@ -143,6 +144,7 @@ const App = () => {
143144
<Route path="/invitation/:organizationId/:applicationId?" element={<InvitationForm/>}/>
144145
<Route path="/accept" element={<Invitation refreshUser={refreshUser}/>}/>
145146
<Route path="/system/:tab?" element={<System/>}/>
147+
<Route path="/profile" element={<Profile setIsAuthenticated={setIsAuthenticated}/>}/>
146148
<Route path="/external/:app?" element={<ExternalApplication/>}/>
147149
<Route path="/application-detail/:manageType/:manageId" element={<ApplicationDetail/>}/>
148150
<Route path="/refresh-route/:path" element={<RefreshRoute/>}/>

client/src/api/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ export function me() {
7777
return fetchJson("/api/v1/users/me");
7878
}
7979

80+
export function deleteUser() {
81+
return fetchDelete("/api/v1/users");
82+
}
83+
84+
8085
export function logout() {
8186
return fetchJson("/api/v1/users/logout");
8287
}

client/src/components/UserMenu.jsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ export const UserMenu = ({setIsAuthenticated}) => {
6767

6868
const renderMenu = () => {
6969
return (<>
70+
<ul>
71+
<li>
72+
<Link to="/profile">
73+
{I18n.t("landing.header.profile")}
74+
</Link>
75+
</li>
76+
</ul>
7077
<ul>
7178
<li>
7279
<a href="/logout" onClick={logoutUser}>{I18n.t(`landing.header.logout`)}</a>

client/src/locale/en.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const en = {
1212
subTitle: "Enabling users in secondary vocational-, higher education and research <strong>to access multiple services with one account</strong>.",
1313
login: "Sign in / sign up",
1414
sup: "EduID ServiceDesk is by invitation only.",
15+
profile: "Profile",
1516
logout: "Logout",
1617
system: "System"
1718
},
@@ -978,6 +979,21 @@ const en = {
978979
},
979980
appAccess: {
980981
roleBasedAccess: "🥸 Toegang verloopt op basis van uitnodiging voor een rol"
982+
},
983+
profile : {
984+
title: "Profile",
985+
info: "Your account was created on {{createdAt}}",
986+
name: "Name",
987+
email: "Mail",
988+
eduPersonPrincipalName: "EPPN",
989+
schacHomeOrganization: "Institution identifier",
990+
superUser: "Congrats🥳, your are a super user",
991+
institutionAdmin: "Congrats🥳, your are an institution admin of {{orgName}}",
992+
delete: "Delete",
993+
deleteConfirmation: "Are you sure you want to remove your account? This action is not reversible.",
994+
attributes: "Attributes",
995+
organization: "Organization(s)",
996+
981997
}
982998
}
983999

client/src/pages/Profile.jsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import React, {Fragment, useEffect, useState} from "react";
2+
import {useAppStore} from "../stores/AppStore";
3+
import "./Profile.scss";
4+
import I18n from "../locale/I18n";
5+
import ConfirmationDialog from "../components/ConfirmationDialog.jsx";
6+
import DOMPurify from "dompurify";
7+
import {Button, ButtonType} from "@surfnet/sds";
8+
import {dateFromEpoch} from "../utils/Date.js";
9+
import {isEmpty, stopEvent} from "../utils/Utils.js";
10+
import {deleteUser, logout} from "../api/index.js";
11+
import {SESSION_STORAGE_LOCATION} from "../utils/Login.js";
12+
import {useNavigate} from "react-router";
13+
import {mainMenuItems} from "../utils/MenuItems.js";
14+
import InputField from "../components/InputField.jsx";
15+
16+
const Profile = ({setIsAuthenticated}) => {
17+
const {user} = useAppStore(state => state);
18+
19+
const [confirmation, setConfirmation] = useState({});
20+
const navigate = useNavigate();
21+
22+
useEffect(() => {
23+
useAppStore.setState({
24+
breadcrumbPaths: [
25+
{path: "/home", value: I18n.t("profile.title"), menuItemName: mainMenuItems.home},
26+
{value: I18n.t("breadCrumb.applications")}
27+
]
28+
});
29+
}, []);
30+
31+
const doDelete = (e, confirmationRequired) => {
32+
stopEvent(e);
33+
if (confirmationRequired) {
34+
setConfirmation({
35+
open: true,
36+
cancel: () => setConfirmation({open: false}),
37+
action: () => doDelete(null, false),
38+
question: I18n.t("profile.deleteConfirmation"),
39+
okButton: I18n.t("forms.delete")
40+
});
41+
} else {
42+
deleteUser().then(() => {
43+
setConfirmation({});
44+
logout()
45+
.then(() => {
46+
useAppStore.setState(() => ({
47+
currentOrganization: {name: ""},
48+
breadcrumbPaths: [],
49+
user: {name: ""}
50+
}));
51+
sessionStorage.removeItem(SESSION_STORAGE_LOCATION);
52+
navigate("/authentication-switch");
53+
setTimeout(() => {
54+
setIsAuthenticated(false);
55+
navigate("/home");
56+
}, 150)
57+
});
58+
})
59+
}
60+
}
61+
62+
const {open, cancel, action, question, okButton} = confirmation;
63+
return (
64+
<div
65+
className="profile-outer-container">
66+
{open && <ConfirmationDialog confirm={action}
67+
cancel={cancel}
68+
confirmationHeader={I18n.t("forms.delete")}
69+
confirmationTxt={okButton}
70+
isDeleteAction={true}
71+
question={question}
72+
/>}
73+
<div className="profile-header-container">
74+
<div className="top-header">
75+
<h1>{user.name}</h1>
76+
</div>
77+
<p dangerouslySetInnerHTML={{
78+
__html: DOMPurify.sanitize(I18n.t("profile.info",
79+
{createdAt: dateFromEpoch(user.createdAt)}))
80+
}}/>
81+
</div>
82+
<div className="profile">
83+
{user.superUser &&
84+
<InputField value={I18n.t("profile.superUser")}
85+
noInput={true}
86+
/>
87+
}
88+
{user.institutionAdmin &&
89+
<InputField name={I18n.t("profile.institutionAdmin")}
90+
noInput={true}
91+
/>
92+
}
93+
<div className="user-container">
94+
<h5>{I18n.t("profile.attributes")}</h5>
95+
{["name", "eduPersonPrincipalName", "email", "schacHomeOrganization"]
96+
.map((attr, index) =>
97+
<Fragment key={index}>
98+
<InputField name={I18n.t(`profile.${attr}`)}
99+
value={user[attr]}
100+
noInput={true}
101+
/>
102+
</Fragment>
103+
)}
104+
105+
{!isEmpty(user.organizationMemberships) &&
106+
<InputField name={I18n.t("profile.organization")}
107+
value={user.organizationMemberships.map(m => m.organization.name).join(", ")}
108+
noInput={true}
109+
/>}
110+
</div>
111+
<div className="delete-container">
112+
<Button onClick={e => doDelete(e, true)}
113+
type={ButtonType.DestructiveSecondary}
114+
txt={I18n.t("profile.delete")}
115+
/>
116+
</div>
117+
</div>
118+
</div>
119+
120+
)
121+
122+
};
123+
export default Profile;

client/src/pages/Profile.scss

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
@use "../styles/mixins" as *;
2+
@use "../index";
3+
4+
div.profile-outer-container {
5+
width: 100%;
6+
display: flex;
7+
flex-direction: column;
8+
height: 100%;
9+
background-color: white;
10+
11+
.profile-header-container {
12+
background-color: var(--sds--color--gray--background);
13+
padding: 25px 50px 25px 50px;
14+
15+
.top-header {
16+
margin-bottom: 15px;
17+
}
18+
}
19+
20+
div.profile {
21+
padding: 25px 50px;
22+
width: 75%;
23+
24+
}
25+
26+
div.user-container {
27+
background-color: white;
28+
display: grid;
29+
border-radius: 8px;
30+
padding: 20px;
31+
border: 1px solid var(--sds--color--gray--background);
32+
grid-template-columns: [first] 1fr [second] 1fr;
33+
34+
h5 {
35+
grid-column: 1 / -1;
36+
}
37+
}
38+
39+
40+
.delete-container {
41+
display: flex;
42+
margin-top: 25px;
43+
44+
button {
45+
margin-left: auto;
46+
}
47+
}
48+
49+
}

server/src/main/java/access/api/UserController.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,18 @@ public ResponseEntity<User> details(@PathVariable("id") Long id, @Parameter(hidd
137137
return ResponseEntity.ok(other);
138138
}
139139

140+
@DeleteMapping
141+
public ResponseEntity<Map<String, Integer>> deleteMe(@Parameter(hidden = true) User user) {
142+
LOG.debug(String.format("/delete for user %s", user.getEduPersonPrincipalName()));
143+
144+
User userFromDB = userRepository.findDetailsById(user.getId())
145+
.orElseThrow(() -> new NotFoundException("User not found"));
146+
147+
userRepository.delete(userFromDB);
148+
149+
return Results.deleteResult();
150+
}
151+
140152
@GetMapping("/logout")
141153
public ResponseEntity<Map<String, Integer>> logout(HttpServletRequest request) {
142154
LOG.debug("/logout");
@@ -158,7 +170,6 @@ public ResponseEntity<Map<String, Integer>> feedback(User user, @RequestBody Map
158170
return Results.okResult();
159171
}
160172

161-
162173
@GetMapping("/search")
163174
public ResponseEntity<Page<Map<String, Object>>> search(@Parameter(hidden = true) User user,
164175
@RequestParam(value = "query", required = false, defaultValue = "") String query,

server/src/main/resources/logback-spring.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
</appender>
99
<logger name="access" level="DEBUG"/>
1010
<logger name="org.hibernate.SQL" level="WARN"/>
11-
<!-- <logger name="org.springframework.security" level="TRACE"/>-->
11+
<logger name="org.springframework.security.web.csrf" level="DEBUG"/>
1212
<logger name="org.springframework.security" level="WARN"/>
1313
<logger name="org.mariadb.jdbc.message.server" level="ERROR"/>
1414
<logger name="com.zaxxer.hikari" level="ERROR"/>

server/src/test/java/access/api/UserControllerTest.java

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
import org.junit.jupiter.api.Test;
1111
import org.springframework.data.domain.Sort;
1212
import org.springframework.http.HttpStatus;
13-
import org.springframework.web.bind.annotation.RequestParam;
1413

1514
import java.util.List;
1615
import java.util.Map;
16+
import java.util.Optional;
1717

1818
import static com.github.tomakehurst.wiremock.client.WireMock.*;
1919
import static io.restassured.RestAssured.given;
@@ -68,6 +68,25 @@ void meManagerWithOauth2Login() throws Exception {
6868
assertEquals("ShareLogics", organization.getName());
6969
}
7070

71+
@Test
72+
void deleteUser() throws Exception {
73+
AccessCookieFilter accessCookieFilter = openIDConnectFlow("/api/v1/users/me", GUEST_SUB);
74+
stubForIdentityProviderByEntityId("http://mock-idp");
75+
76+
given()
77+
.when()
78+
.filter(accessCookieFilter.cookieFilter())
79+
.header(accessCookieFilter.csrfToken().getHeaderName(), accessCookieFilter.csrfToken().getToken())
80+
.accept(ContentType.JSON)
81+
.contentType(ContentType.JSON)
82+
.delete("/api/v1/users")
83+
.then()
84+
.statusCode(HttpStatus.NO_CONTENT.value());
85+
86+
Optional<User> optionalUser = userRepository.findBySubIgnoreCase(GUEST_SUB);
87+
assertTrue(optionalUser.isEmpty());
88+
}
89+
7190
@Test
7291
void meManagerWithMockLogin() {
7392
AccessCookieFilter accessCookieFilter = mockLoginFlow(MANAGE_SUB);
@@ -272,15 +291,15 @@ void searchSuperUser() {
272291
.filter(accessCookieFilter.cookieFilter())
273292
.accept(ContentType.JSON)
274293
.contentType(ContentType.JSON)
275-
.queryParam("query","doe")
294+
.queryParam("query", "doe")
276295
.queryParam("pageNumber", 0)
277296
.queryParam("pageSize", 10)
278297
.queryParam("sort", "name")
279298
.queryParam("sortDirection", Sort.Direction.ASC)
280299
.get("/api/v1/users/search")
281300
.as(new TypeRef<>() {
282301
});
283-
assertEquals(4, ((List)results.get("content")).size());
302+
assertEquals(4, ((List) results.get("content")).size());
284303
}
285304

286305
@SneakyThrows

0 commit comments

Comments
 (0)