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. +

+
+ + setNewPassword(e.target.value)} + placeholder="Enter new password" + required + minLength={8} + /> + + + setConfirmPassword(e.target.value)} + placeholder="Confirm new password" + required + minLength={8} + /> + +
+ +
+
+
); } 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 && ( + +
{ + e.preventDefault(); + if (pwValue.length < 8) { + toast({ title: 'Password must be at least 8 characters', variant: 'error' }); + return; + } + try { + await resetPassword.mutateAsync({ userId: pwTarget.id, password: pwValue }); + toast({ title: `Password reset for ${pwTarget.name}`, variant: 'success' }); + setPwTarget(null); + setPwValue(''); + } catch (err) { + toast({ title: 'Reset failed', description: String(err), variant: 'error' }); + } + }} + style={{ display: 'flex', flexDirection: 'column', gap: 16 }} + > + + setPwValue(e.target.value)} + required + minLength={8} + /> + +
+ + +
+
+
+ )} ); }