Skip to content

Commit d1fddd2

Browse files
hotzenklotzphilippottocoderabbitai[bot]
authored
Add Account Settings page (#8672)
This PR adds a new "Account Settings" page to host various other sub-pages for: - changing email (soon) - changing password - appearance/theme - passkeys (future) - API Auth Token <img width="1269" alt="Screenshot 2025-06-25 at 10 45 45" src="https://github.com/user-attachments/assets/848cc79b-b636-4de1-b495-e15059e7d452" /> <img width="1268" alt="Screenshot 2025-06-25 at 10 45 54" src="https://github.com/user-attachments/assets/fcfb3c08-8a58-450e-b50d-96bd58ed9f1a" /> <img width="1269" alt="Screenshot 2025-06-25 at 10 46 01" src="https://github.com/user-attachments/assets/4a7da90e-a2ed-4a60-ac9b-ebff65d8e140" /> ### URL of deployed dev instance (used for testing): - https://accountsettingspage.webknossos.xyz/ ### Steps to test: - Go to new Account settings page - Change PW - Try dark/light mode ### Issues: - Blocked by #8671 - Blocked by #8679 - fixes #5408 ------ (Please delete unneeded items, merge only when none are left open) - [x] Updated [changelog](../blob/master/CHANGELOG.unreleased.md#unreleased) - [ ] Updated [migration guide](../blob/master/MIGRATIONS.unreleased.md#unreleased) if applicable - [ ] Updated [documentation](../blob/master/docs) if applicable - [ ] Adapted [wk-libs python client](https://github.com/scalableminds/webknossos-libs/tree/master/webknossos/webknossos/client) if relevant API parts change - [ ] Removed dev-only changes like prints and application.conf edits - [x] Considered [common edge cases](../blob/master/.github/common_edge_cases.md) - [ ] Needs datastore update after deployment --------- Co-authored-by: Philipp Otto <philippotto@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Philipp Otto <philipp.4096@gmail.com>
1 parent c71944c commit d1fddd2

34 files changed

+1816
-901
lines changed

app/controllers/OrganizationController.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ class OrganizationController @Inject()(
154154
_ <- Fox.fromBool(request.identity.isAdminOf(organization._id)) ?~> "notAllowed" ~> FORBIDDEN
155155
_ <- organizationDAO.updateFields(organization._id, name, newUserMailingList)
156156
updated <- organizationDAO.findOne(organization._id)
157-
organizationJson <- organizationService.publicWrites(updated)
157+
organizationJson <- organizationService.publicWrites(updated, Some(request.identity))
158158
} yield Ok(organizationJson)
159159
}
160160
}

biome.json

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,10 @@
100100
},
101101
"overrides": [
102102
{
103-
"include": [
104-
"**/package.json"
105-
],
103+
"include": ["**/package.json"],
106104
"formatter": {
107105
"lineWidth": 1
108106
}
109107
}
110108
]
111-
}
109+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { ExportOutlined, SwapOutlined } from "@ant-design/icons";
2+
import { getAuthToken, revokeAuthToken } from "admin/rest_api";
3+
import { Button, Col, Row, Spin, Typography } from "antd";
4+
import { useWkSelector } from "libs/react_hooks";
5+
import Toast from "libs/toast";
6+
import { useEffect, useState } from "react";
7+
import { SettingsCard } from "./helpers/settings_card";
8+
import { SettingsTitle } from "./helpers/settings_title";
9+
10+
const { Text } = Typography;
11+
12+
function AccountAuthTokenView() {
13+
const activeUser = useWkSelector((state) => state.activeUser);
14+
const [isLoading, setIsLoading] = useState<boolean>(true);
15+
const [currentToken, setCurrentToken] = useState<string>("");
16+
17+
useEffect(() => {
18+
fetchData();
19+
}, []);
20+
21+
async function fetchData(): Promise<void> {
22+
try {
23+
const token = await getAuthToken();
24+
setCurrentToken(token);
25+
} catch (error) {
26+
Toast.error("Failed to fetch auth token. Please refresh the page to try again.");
27+
console.error("Failed to fetch auth token:", error);
28+
} finally {
29+
setIsLoading(false);
30+
}
31+
}
32+
33+
const handleRevokeToken = async (): Promise<void> => {
34+
try {
35+
setIsLoading(true);
36+
await revokeAuthToken();
37+
const token = await getAuthToken();
38+
setCurrentToken(token);
39+
} finally {
40+
setIsLoading(false);
41+
}
42+
};
43+
44+
const APIitems = [
45+
{
46+
title: "Auth Token",
47+
value: (
48+
<Text code copyable>
49+
{currentToken}
50+
</Text>
51+
),
52+
},
53+
{
54+
title: "Token Revocation",
55+
explanation:
56+
"Revoke your token if it has been compromised or if you suspect someone else has gained access to it. This will invalidate all active sessions.",
57+
value: (
58+
<Button icon={<SwapOutlined />} type="primary" ghost onClick={handleRevokeToken}>
59+
Revoke and Generate New Token
60+
</Button>
61+
),
62+
},
63+
...(activeUser
64+
? [
65+
{
66+
title: "Organization ID",
67+
value: (
68+
<Text code copyable>
69+
{activeUser.organization}
70+
</Text>
71+
),
72+
},
73+
]
74+
: []),
75+
{
76+
title: "API Documentation",
77+
value: (
78+
<a href="https://docs.webknossos.org/webknossos-py/index.html">
79+
Read the docs <ExportOutlined />
80+
</a>
81+
),
82+
},
83+
];
84+
85+
return (
86+
<div>
87+
<SettingsTitle
88+
title="API Authorization"
89+
description="Access the WEBKNOSSO Python API with your API token"
90+
/>
91+
<Spin size="large" spinning={isLoading}>
92+
<Row gutter={[24, 24]} style={{ marginBottom: 24 }}>
93+
{APIitems.map((item) => (
94+
<Col span={12} key={item.title}>
95+
<SettingsCard
96+
title={item.title}
97+
description={item.value}
98+
explanation={item.explanation}
99+
/>
100+
</Col>
101+
))}
102+
</Row>
103+
</Spin>
104+
</div>
105+
);
106+
}
107+
108+
export default AccountAuthTokenView;
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { EditOutlined, LockOutlined } from "@ant-design/icons";
2+
import { changePassword, logoutUser } from "admin/rest_api";
3+
import { Alert, Button, Col, Form, Input, Row, Space } from "antd";
4+
import Toast from "libs/toast";
5+
import messages from "messages";
6+
import { useState } from "react";
7+
import { useHistory } from "react-router-dom";
8+
import { logoutUserAction } from "viewer/model/actions/user_actions";
9+
import Store from "viewer/store";
10+
import { SettingsCard } from "./helpers/settings_card";
11+
import { SettingsTitle } from "./helpers/settings_title";
12+
const FormItem = Form.Item;
13+
const { Password } = Input;
14+
15+
const MIN_PASSWORD_LENGTH = 8;
16+
17+
function AccountPasswordView() {
18+
const history = useHistory();
19+
const [form] = Form.useForm();
20+
const [isResetPasswordVisible, setResetPasswordVisible] = useState(false);
21+
22+
function onFinish(formValues: Record<string, any>) {
23+
changePassword(formValues)
24+
.then(async () => {
25+
Toast.success(messages["auth.reset_pw_confirmation"]);
26+
await logoutUser();
27+
Store.dispatch(logoutUserAction());
28+
history.push("/auth/login");
29+
})
30+
.catch((error) => {
31+
console.error("Password change failed:", error);
32+
Toast.error("Failed to change password. Please try again.");
33+
});
34+
}
35+
36+
function checkPasswordsAreMatching(value: string, otherPasswordFieldKey: string[]) {
37+
const otherFieldValue = form.getFieldValue(otherPasswordFieldKey);
38+
39+
if (value && otherFieldValue) {
40+
if (value !== otherFieldValue) {
41+
return Promise.reject(new Error(messages["auth.registration_password_mismatch"]));
42+
} else if (form.getFieldError(otherPasswordFieldKey).length > 0) {
43+
// If the other password field still has errors, revalidate it.
44+
form.validateFields([otherPasswordFieldKey]);
45+
}
46+
}
47+
48+
return Promise.resolve();
49+
}
50+
51+
function getPasswordComponent() {
52+
return isResetPasswordVisible ? (
53+
<Form onFinish={onFinish} form={form}>
54+
<FormItem
55+
name="oldPassword"
56+
rules={[
57+
{
58+
required: true,
59+
message: messages["auth.reset_old_password"],
60+
},
61+
]}
62+
>
63+
<Password
64+
prefix={
65+
<LockOutlined
66+
style={{
67+
fontSize: 13,
68+
}}
69+
/>
70+
}
71+
placeholder="Old Password"
72+
/>
73+
</FormItem>
74+
<FormItem
75+
hasFeedback
76+
name={["password", "password1"]}
77+
rules={[
78+
{
79+
required: true,
80+
message: messages["auth.reset_new_password"],
81+
},
82+
{
83+
min: MIN_PASSWORD_LENGTH,
84+
message: messages["auth.registration_password_length"],
85+
},
86+
{
87+
validator: (_, value: string) =>
88+
checkPasswordsAreMatching(value, ["password", "password2"]),
89+
},
90+
]}
91+
>
92+
<Password
93+
prefix={
94+
<LockOutlined
95+
style={{
96+
fontSize: 13,
97+
}}
98+
/>
99+
}
100+
placeholder="New Password"
101+
/>
102+
</FormItem>
103+
<FormItem
104+
hasFeedback
105+
name={["password", "password2"]}
106+
rules={[
107+
{
108+
required: true,
109+
message: messages["auth.reset_new_password2"],
110+
},
111+
{
112+
min: MIN_PASSWORD_LENGTH,
113+
message: messages["auth.registration_password_length"],
114+
},
115+
{
116+
validator: (_, value: string) =>
117+
checkPasswordsAreMatching(value, ["password", "password1"]),
118+
},
119+
]}
120+
>
121+
<Password
122+
prefix={
123+
<LockOutlined
124+
style={{
125+
fontSize: 13,
126+
}}
127+
/>
128+
}
129+
placeholder="Confirm New Password"
130+
/>
131+
</FormItem>
132+
<Alert
133+
type="info"
134+
message={messages["auth.reset_logout"]}
135+
showIcon
136+
style={{
137+
marginBottom: 24,
138+
}}
139+
/>
140+
<FormItem>
141+
<Space>
142+
<Button onClick={() => setResetPasswordVisible(false)}>Cancel</Button>
143+
<Button type="primary" htmlType="submit">
144+
Update Password
145+
</Button>
146+
</Space>
147+
</FormItem>
148+
</Form>
149+
) : (
150+
"***********"
151+
);
152+
}
153+
154+
function handleResetPassword() {
155+
setResetPasswordVisible(true);
156+
}
157+
158+
const passKeyList = [
159+
{
160+
title: "Coming soon",
161+
value: "Passwordless login with passkeys is coming soon",
162+
// action: <Button type="default" shape="circle" icon={<DeleteOutlined />} size="small" />,
163+
action: undefined,
164+
},
165+
];
166+
167+
return (
168+
<div>
169+
<SettingsTitle title="Password" description="Manage and update your password" />
170+
<Row gutter={[24, 24]} style={{ marginBottom: 24 }}>
171+
<Col span={12}>
172+
<SettingsCard
173+
title="Password"
174+
description={getPasswordComponent()}
175+
action={
176+
<Button
177+
type="default"
178+
shape="circle"
179+
icon={<EditOutlined />}
180+
size="small"
181+
onClick={handleResetPassword}
182+
/>
183+
}
184+
/>
185+
</Col>
186+
</Row>
187+
188+
<SettingsTitle title="Passkeys" description="Login passwordless with Passkeys" />
189+
<Row gutter={[24, 24]} style={{ marginBottom: 24 }}>
190+
{passKeyList.map((item) => (
191+
<Col span={12} key={item.title}>
192+
<SettingsCard title={item.title} description={item.value} action={item.action} />
193+
</Col>
194+
))}
195+
</Row>
196+
</div>
197+
);
198+
}
199+
200+
export default AccountPasswordView;

0 commit comments

Comments
 (0)