feat: server admin password reset via tenant portal
- POST /api/tenant/server/admin-password — resets server's built-in admin password via M2M API call to the tenant's server - Settings page: "Server Admin Password" card - ServerApiClient.resetServerAdminPassword() calls server's password reset endpoint with M2M token Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -156,6 +156,19 @@ public class ServerApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reset the built-in admin password on a tenant's server. */
|
||||||
|
public void resetServerAdminPassword(String serverEndpoint, String newPassword) {
|
||||||
|
RestClient.create(serverEndpoint)
|
||||||
|
.post()
|
||||||
|
.uri("/api/v1/admin/users/user:admin/password")
|
||||||
|
.header("Authorization", "Bearer " + getAccessToken())
|
||||||
|
.header("X-Cameleer-Protocol-Version", "1")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(Map.of("password", newPassword))
|
||||||
|
.retrieve()
|
||||||
|
.toBodilessEntity();
|
||||||
|
}
|
||||||
|
|
||||||
public record ServerHealthResponse(boolean healthy, String status) {}
|
public record ServerHealthResponse(boolean healthy, String status) {}
|
||||||
|
|
||||||
private synchronized String getAccessToken() {
|
private synchronized String getAccessToken() {
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ public class TenantPortalController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/server/admin-password")
|
||||||
|
public ResponseEntity<Void> resetServerAdminPassword(@RequestBody PasswordChangeRequest body) {
|
||||||
|
try {
|
||||||
|
portalService.resetServerAdminPassword(body.password());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
} catch (IllegalStateException e) {
|
||||||
|
return ResponseEntity.badRequest().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/password")
|
@PostMapping("/password")
|
||||||
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
|
public ResponseEntity<Void> changeOwnPassword(@AuthenticationPrincipal Jwt jwt,
|
||||||
@RequestBody PasswordChangeRequest body) {
|
@RequestBody PasswordChangeRequest body) {
|
||||||
|
|||||||
@@ -176,6 +176,18 @@ public class TenantPortalService {
|
|||||||
logtoClient.assignOrganizationRole(orgId, userId, roleId);
|
logtoClient.assignOrganizationRole(orgId, userId, roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void resetServerAdminPassword(String newPassword) {
|
||||||
|
TenantEntity tenant = resolveTenant();
|
||||||
|
String endpoint = tenant.getServerEndpoint();
|
||||||
|
if (endpoint == null || endpoint.isBlank()) {
|
||||||
|
throw new IllegalStateException("Server not provisioned yet");
|
||||||
|
}
|
||||||
|
if (newPassword == null || newPassword.length() < 8) {
|
||||||
|
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||||
|
}
|
||||||
|
serverApiClient.resetServerAdminPassword(endpoint, newPassword);
|
||||||
|
}
|
||||||
|
|
||||||
public void changePassword(String userId, String newPassword) {
|
public void changePassword(String userId, String newPassword) {
|
||||||
if (newPassword == null || newPassword.length() < 8) {
|
if (newPassword == null || newPassword.length() < 8) {
|
||||||
throw new IllegalArgumentException("Password must be at least 8 characters");
|
throw new IllegalArgumentException("Password must be at least 8 characters");
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ export function useRemoveTeamMember() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useResetServerAdminPassword() {
|
||||||
|
return useMutation<void, Error, string>({
|
||||||
|
mutationFn: (password) => api.post('/tenant/server/admin-password', { password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useChangeOwnPassword() {
|
export function useChangeOwnPassword() {
|
||||||
return useMutation<void, Error, string>({
|
return useMutation<void, Error, string>({
|
||||||
mutationFn: (password) => api.post('/tenant/password', { password }),
|
mutationFn: (password) => api.post('/tenant/password', { password }),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import { useTenantSettings, useChangeOwnPassword } from '../../api/tenant-hooks';
|
import { useTenantSettings, useChangeOwnPassword, useResetServerAdminPassword } 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';
|
||||||
|
|
||||||
@@ -26,10 +26,12 @@ 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 changePassword = useChangeOwnPassword();
|
||||||
|
const resetServerAdmin = useResetServerAdminPassword();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [serverAdminPw, setServerAdminPw] = useState('');
|
||||||
|
|
||||||
async function handleChangePassword(e: React.FormEvent) {
|
async function handleChangePassword(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -140,6 +142,46 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Server Admin Password">
|
||||||
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
|
Reset the built-in admin password for your server dashboard (local login at <code>/login?local</code>).
|
||||||
|
</p>
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (serverAdminPw.length < 8) {
|
||||||
|
toast({ title: 'Password must be at least 8 characters', variant: 'error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await resetServerAdmin.mutateAsync(serverAdminPw);
|
||||||
|
toast({ title: 'Server admin password reset successfully', variant: 'success' });
|
||||||
|
setServerAdminPw('');
|
||||||
|
} catch (err) {
|
||||||
|
toast({ title: 'Failed to reset server admin password', description: String(err), variant: 'error' });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}
|
||||||
|
>
|
||||||
|
<FormField label="New admin password" htmlFor="server-admin-pw">
|
||||||
|
<Input
|
||||||
|
id="server-admin-pw"
|
||||||
|
type="password"
|
||||||
|
value={serverAdminPw}
|
||||||
|
onChange={(e) => setServerAdminPw(e.target.value)}
|
||||||
|
placeholder="Enter new admin password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
<div>
|
||||||
|
<Button type="submit" variant="primary" loading={resetServerAdmin.isPending}>
|
||||||
|
Reset Admin Password
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user