From 4121bd64b226ba76f470aa7cef0d1e9308189f70 Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Sat, 11 Apr 2026 09:19:48 +0200
Subject: [PATCH] feat: password management for tenant portal
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- POST /api/tenant/password — change own Logto password
- POST /api/tenant/team/{userId}/password — reset team member password
- Settings page: "Change Password" card with confirm field
- Team page: "Reset Password" button per member with inline form
- LogtoManagementClient.updateUserPassword() via Logto Management API
Co-Authored-By: Claude Opus 4.6 (1M context)
---
.../saas/identity/LogtoManagementClient.java | 12 ++++
.../saas/portal/TenantPortalController.java | 26 +++++++
.../saas/portal/TenantPortalService.java | 26 +++++++
ui/src/api/tenant-hooks.ts | 13 ++++
ui/src/pages/tenant/SettingsPage.tsx | 67 +++++++++++++++++-
ui/src/pages/tenant/TeamPage.tsx | 68 +++++++++++++++++--
6 files changed, 205 insertions(+), 7 deletions(-)
diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
index 77dea8b..3072a27 100644
--- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
+++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
@@ -398,6 +398,18 @@ public class LogtoManagementClient {
.toBodilessEntity();
}
+ /** Update a user's password. */
+ public void updateUserPassword(String userId, String newPassword) {
+ if (!isAvailable()) throw new IllegalStateException("Logto not configured");
+ restClient.patch()
+ .uri(config.getLogtoEndpoint() + "/api/users/" + userId)
+ .header("Authorization", "Bearer " + getAccessToken())
+ .contentType(MediaType.APPLICATION_JSON)
+ .body(Map.of("password", newPassword))
+ .retrieve()
+ .toBodilessEntity();
+ }
+
/** Get a user by ID. Returns username, primaryEmail, name. */
@SuppressWarnings("unchecked")
public Map getUser(String userId) {
diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java
index c6c74b1..da6f5ba 100644
--- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java
+++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java
@@ -4,6 +4,8 @@ import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
import net.siegeln.cameleer.saas.config.TenantContext;
import org.springframework.http.ResponseEntity;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -39,6 +41,8 @@ public class TenantPortalController {
public record RoleChangeRequest(String roleId) {}
+ public record PasswordChangeRequest(String password) {}
+
// --- Endpoints ---
@GetMapping("/dashboard")
@@ -79,6 +83,28 @@ public class TenantPortalController {
return ResponseEntity.ok().build();
}
+ @PostMapping("/password")
+ public ResponseEntity changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
+ @RequestBody PasswordChangeRequest body) {
+ try {
+ portalService.changePassword(jwt.getSubject(), body.password());
+ return ResponseEntity.noContent().build();
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().build();
+ }
+ }
+
+ @PostMapping("/team/{userId}/password")
+ public ResponseEntity resetTeamMemberPassword(@PathVariable String userId,
+ @RequestBody PasswordChangeRequest body) {
+ try {
+ portalService.resetTeamMemberPassword(userId, body.password());
+ return ResponseEntity.noContent().build();
+ } catch (IllegalArgumentException e) {
+ return ResponseEntity.badRequest().build();
+ }
+ }
+
@PostMapping("/server/restart")
public ResponseEntity restartServer() {
portalService.restartServer();
diff --git a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java
index f29db99..0a79e0c 100644
--- a/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java
+++ b/src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java
@@ -176,6 +176,32 @@ public class TenantPortalService {
logtoClient.assignOrganizationRole(orgId, userId, roleId);
}
+ public void changePassword(String userId, String newPassword) {
+ if (newPassword == null || newPassword.length() < 8) {
+ throw new IllegalArgumentException("Password must be at least 8 characters");
+ }
+ logtoClient.updateUserPassword(userId, newPassword);
+ }
+
+ public void resetTeamMemberPassword(String userId, String newPassword) {
+ TenantEntity tenant = resolveTenant();
+ String orgId = tenant.getLogtoOrgId();
+ if (orgId == null || orgId.isBlank()) {
+ throw new IllegalStateException("Tenant has no Logto organization configured");
+ }
+ // Verify the target user belongs to this tenant's org
+ var members = logtoClient.listOrganizationMembers(orgId);
+ boolean isMember = members.stream()
+ .anyMatch(m -> userId.equals(String.valueOf(m.get("id"))));
+ if (!isMember) {
+ throw new IllegalArgumentException("User is not a member of this organization");
+ }
+ if (newPassword == null || newPassword.length() < 8) {
+ throw new IllegalArgumentException("Password must be at least 8 characters");
+ }
+ logtoClient.updateUserPassword(userId, newPassword);
+ }
+
public TenantSettingsData getSettings() {
TenantEntity tenant = resolveTenant();
String publicEndpoint = provisioningProps.publicProtocol() + "://"
diff --git a/ui/src/api/tenant-hooks.ts b/ui/src/api/tenant-hooks.ts
index a411672..bf4b7e2 100644
--- a/ui/src/api/tenant-hooks.ts
+++ b/ui/src/api/tenant-hooks.ts
@@ -94,6 +94,19 @@ export function useRemoveTeamMember() {
});
}
+export function useChangeOwnPassword() {
+ return useMutation({
+ mutationFn: (password) => api.post('/tenant/password', { password }),
+ });
+}
+
+export function useResetTeamMemberPassword() {
+ return useMutation({
+ mutationFn: ({ userId, password }) =>
+ api.post(`/tenant/team/${userId}/password`, { password }),
+ });
+}
+
export function useTenantSettings() {
return useQuery({
queryKey: ['tenant', 'settings'],
diff --git a/ui/src/pages/tenant/SettingsPage.tsx b/ui/src/pages/tenant/SettingsPage.tsx
index d9fc9f7..7c90f76 100644
--- a/ui/src/pages/tenant/SettingsPage.tsx
+++ b/ui/src/pages/tenant/SettingsPage.tsx
@@ -1,10 +1,15 @@
+import { useState } from 'react';
import {
Alert,
Badge,
+ Button,
Card,
+ FormField,
+ Input,
Spinner,
+ useToast,
} from '@cameleer/design-system';
-import { useTenantSettings } from '../../api/tenant-hooks';
+import { useTenantSettings, useChangeOwnPassword } from '../../api/tenant-hooks';
import { tierColor } from '../../utils/tier';
import styles from '../../styles/platform.module.css';
@@ -20,6 +25,31 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
export function SettingsPage() {
const { data, isLoading, isError } = useTenantSettings();
+ const changePassword = useChangeOwnPassword();
+ const { toast } = useToast();
+
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+
+ async function handleChangePassword(e: React.FormEvent) {
+ e.preventDefault();
+ if (newPassword.length < 8) {
+ toast({ title: 'Password must be at least 8 characters', variant: 'error' });
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ toast({ title: 'Passwords do not match', variant: 'error' });
+ return;
+ }
+ try {
+ await changePassword.mutateAsync(newPassword);
+ toast({ title: 'Password changed successfully', variant: 'success' });
+ setNewPassword('');
+ setConfirmPassword('');
+ } catch (err) {
+ toast({ title: 'Failed to change password', description: String(err), variant: 'error' });
+ }
+ }
if (isLoading) {
return (
@@ -75,6 +105,41 @@ export function SettingsPage() {
To change your tier or other billing-related settings, please contact support.
+
+
+
+ Update your login password. Minimum 8 characters.
+
+
+
);
}
diff --git a/ui/src/pages/tenant/TeamPage.tsx b/ui/src/pages/tenant/TeamPage.tsx
index d5b218f..4d5df35 100644
--- a/ui/src/pages/tenant/TeamPage.tsx
+++ b/ui/src/pages/tenant/TeamPage.tsx
@@ -18,6 +18,7 @@ import {
useTenantTeam,
useInviteTeamMember,
useRemoveTeamMember,
+ useResetTeamMemberPassword,
} from '../../api/tenant-hooks';
import styles from '../../styles/platform.module.css';
@@ -57,6 +58,7 @@ export function TeamPage() {
const { data: rawTeam, isLoading, isError } = useTenantTeam();
const inviteMember = useInviteTeamMember();
const removeMember = useRemoveTeamMember();
+ const resetPassword = useResetTeamMemberPassword();
const { toast } = useToast();
const [showInvite, setShowInvite] = useState(false);
@@ -64,6 +66,8 @@ export function TeamPage() {
const [inviteRole, setInviteRole] = useState('viewer');
const [removeTarget, setRemoveTarget] = useState(null);
+ const [pwTarget, setPwTarget] = useState(null);
+ const [pwValue, setPwValue] = useState('');
const team: TeamMember[] = (rawTeam ?? []).map(toMember).filter((m) => m.id !== '');
@@ -89,12 +93,20 @@ export function TeamPage() {
key: 'id',
header: 'Actions',
render: (_v, row) => (
-
+
+
+
+
),
},
];
@@ -231,6 +243,50 @@ export function TeamPage() {
variant="danger"
loading={removeMember.isPending}
/>
+
+ {/* Reset password inline form */}
+ {pwTarget && (
+
+
+
+ )}
);
}