feat: password management for tenant portal
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -398,6 +398,18 @@ public class LogtoManagementClient {
|
|||||||
.toBodilessEntity();
|
.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. */
|
/** Get a user by ID. Returns username, primaryEmail, name. */
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public Map<String, Object> getUser(String userId) {
|
public Map<String, Object> getUser(String userId) {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import net.siegeln.cameleer.saas.certificate.TenantCaCertEntity;
|
|||||||
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
|
import net.siegeln.cameleer.saas.certificate.TenantCaCertService;
|
||||||
import net.siegeln.cameleer.saas.config.TenantContext;
|
import net.siegeln.cameleer.saas.config.TenantContext;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PatchMapping;
|
import org.springframework.web.bind.annotation.PatchMapping;
|
||||||
@@ -39,6 +41,8 @@ public class TenantPortalController {
|
|||||||
|
|
||||||
public record RoleChangeRequest(String roleId) {}
|
public record RoleChangeRequest(String roleId) {}
|
||||||
|
|
||||||
|
public record PasswordChangeRequest(String password) {}
|
||||||
|
|
||||||
// --- Endpoints ---
|
// --- Endpoints ---
|
||||||
|
|
||||||
@GetMapping("/dashboard")
|
@GetMapping("/dashboard")
|
||||||
@@ -79,6 +83,28 @@ public class TenantPortalController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/password")
|
||||||
|
public ResponseEntity<Void> 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<Void> 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")
|
@PostMapping("/server/restart")
|
||||||
public ResponseEntity<Void> restartServer() {
|
public ResponseEntity<Void> restartServer() {
|
||||||
portalService.restartServer();
|
portalService.restartServer();
|
||||||
|
|||||||
@@ -176,6 +176,32 @@ public class TenantPortalService {
|
|||||||
logtoClient.assignOrganizationRole(orgId, userId, roleId);
|
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() {
|
public TenantSettingsData getSettings() {
|
||||||
TenantEntity tenant = resolveTenant();
|
TenantEntity tenant = resolveTenant();
|
||||||
String publicEndpoint = provisioningProps.publicProtocol() + "://"
|
String publicEndpoint = provisioningProps.publicProtocol() + "://"
|
||||||
|
|||||||
@@ -94,6 +94,19 @@ export function useRemoveTeamMember() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChangeOwnPassword() {
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (password) => api.post('/tenant/password', { password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetTeamMemberPassword() {
|
||||||
|
return useMutation<void, Error, { userId: string; password: string }>({
|
||||||
|
mutationFn: ({ userId, password }) =>
|
||||||
|
api.post(`/tenant/team/${userId}/password`, { password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTenantSettings() {
|
export function useTenantSettings() {
|
||||||
return useQuery<TenantSettings>({
|
return useQuery<TenantSettings>({
|
||||||
queryKey: ['tenant', 'settings'],
|
queryKey: ['tenant', 'settings'],
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
FormField,
|
||||||
|
Input,
|
||||||
Spinner,
|
Spinner,
|
||||||
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useTenantSettings } from '../../api/tenant-hooks';
|
import { useTenantSettings, useChangeOwnPassword } from '../../api/tenant-hooks';
|
||||||
import { tierColor } from '../../utils/tier';
|
import { tierColor } from '../../utils/tier';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
@@ -20,6 +25,31 @@ function statusColor(status: string): 'success' | 'error' | 'warning' | 'auto' {
|
|||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { data, isLoading, isError } = useTenantSettings();
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -75,6 +105,41 @@ export function SettingsPage() {
|
|||||||
To change your tier or other billing-related settings, please contact support.
|
To change your tier or other billing-related settings, please contact support.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Change Password">
|
||||||
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
|
Update your login password. Minimum 8 characters.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
|
||||||
|
<FormField label="New password" htmlFor="new-pw">
|
||||||
|
<Input
|
||||||
|
id="new-pw"
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Enter new password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<FormField label="Confirm password" htmlFor="confirm-pw">
|
||||||
|
<Input
|
||||||
|
id="confirm-pw"
|
||||||
|
type="password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="Confirm new password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" variant="primary" loading={changePassword.isPending}>
|
||||||
|
Change Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useTenantTeam,
|
useTenantTeam,
|
||||||
useInviteTeamMember,
|
useInviteTeamMember,
|
||||||
useRemoveTeamMember,
|
useRemoveTeamMember,
|
||||||
|
useResetTeamMemberPassword,
|
||||||
} from '../../api/tenant-hooks';
|
} from '../../api/tenant-hooks';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ export function TeamPage() {
|
|||||||
const { data: rawTeam, isLoading, isError } = useTenantTeam();
|
const { data: rawTeam, isLoading, isError } = useTenantTeam();
|
||||||
const inviteMember = useInviteTeamMember();
|
const inviteMember = useInviteTeamMember();
|
||||||
const removeMember = useRemoveTeamMember();
|
const removeMember = useRemoveTeamMember();
|
||||||
|
const resetPassword = useResetTeamMemberPassword();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [showInvite, setShowInvite] = useState(false);
|
const [showInvite, setShowInvite] = useState(false);
|
||||||
@@ -64,6 +66,8 @@ export function TeamPage() {
|
|||||||
const [inviteRole, setInviteRole] = useState('viewer');
|
const [inviteRole, setInviteRole] = useState('viewer');
|
||||||
|
|
||||||
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null);
|
const [removeTarget, setRemoveTarget] = useState<TeamMember | null>(null);
|
||||||
|
const [pwTarget, setPwTarget] = useState<TeamMember | null>(null);
|
||||||
|
const [pwValue, setPwValue] = useState('');
|
||||||
|
|
||||||
const team: TeamMember[] = (rawTeam ?? []).map(toMember).filter((m) => m.id !== '');
|
const team: TeamMember[] = (rawTeam ?? []).map(toMember).filter((m) => m.id !== '');
|
||||||
|
|
||||||
@@ -89,12 +93,20 @@ export function TeamPage() {
|
|||||||
key: 'id',
|
key: 'id',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
render: (_v, row) => (
|
render: (_v, row) => (
|
||||||
<Button
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
variant="danger"
|
<Button
|
||||||
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
|
variant="secondary"
|
||||||
>
|
onClick={(e) => { e.stopPropagation(); setPwTarget(row); setPwValue(''); }}
|
||||||
Remove
|
>
|
||||||
</Button>
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -231,6 +243,50 @@ export function TeamPage() {
|
|||||||
variant="danger"
|
variant="danger"
|
||||||
loading={removeMember.isPending}
|
loading={removeMember.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Reset password inline form */}
|
||||||
|
{pwTarget && (
|
||||||
|
<Card title={`Reset password for ${pwTarget.name}`}>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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 }}
|
||||||
|
>
|
||||||
|
<FormField label="New password" htmlFor="reset-pw">
|
||||||
|
<Input
|
||||||
|
id="reset-pw"
|
||||||
|
type="password"
|
||||||
|
placeholder="Min. 8 characters"
|
||||||
|
value={pwValue}
|
||||||
|
onChange={(e) => setPwValue(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button type="submit" variant="primary" loading={resetPassword.isPending}>
|
||||||
|
Reset Password
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setPwTarget(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user