feat: password management for tenant portal
All checks were successful
CI / build (push) Successful in 1m15s
CI / docker (push) Successful in 47s

- 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:
hsiegeln
2026-04-11 09:19:48 +02:00
parent dd8553a8b4
commit 4121bd64b2
6 changed files with 205 additions and 7 deletions

View File

@@ -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() {
return useQuery<TenantSettings>({
queryKey: ['tenant', 'settings'],

View File

@@ -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.
</p>
</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>
);
}

View File

@@ -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<TeamMember | null>(null);
const [pwTarget, setPwTarget] = useState<TeamMember | null>(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) => (
<Button
variant="danger"
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
>
Remove
</Button>
<div style={{ display: 'flex', gap: 6 }}>
<Button
variant="secondary"
onClick={(e) => { e.stopPropagation(); setPwTarget(row); setPwValue(''); }}
>
Reset Password
</Button>
<Button
variant="danger"
onClick={(e) => { e.stopPropagation(); setRemoveTarget(row); }}
>
Remove
</Button>
</div>
),
},
];
@@ -231,6 +243,50 @@ export function TeamPage() {
variant="danger"
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>
);
}