Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions frontend/src/features/auth/ChangePasswordCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
// policy, as registration and reset: a password this card calls acceptable is
// one the API accepts. The current password is the proof of identity, so a
// wrong one is reported on that field and nowhere else.
//
// An account that signs in through an identity provider has no password here
// (#850): it is told so up front instead of being handed a form that can only
// fail. If that cannot be read, the form shows and the server's
// no_local_password answer switches the card, as before.

import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
Expand All @@ -18,11 +23,13 @@ import { toast } from 'sonner';
import { useNavigate } from 'react-router';

import { Button, Field, Input, useSuccessFeedback } from '../../shared/ds';
import { SkeletonRows } from '../../shared/ui';
import { useUIStore } from '../../store/uiStore';
import { useI18n } from '../../hooks/useI18n';
import { useAuthStore } from '../../hooks/useAuthStore';
import { setAccessToken } from '../../lib/session';
import { PasswordStrength } from './PasswordStrength';
import { useHasLocalPassword } from './useHasLocalPassword';
import {
changePassword,
type ChangePasswordErrorBody,
Expand Down Expand Up @@ -59,7 +66,10 @@ export function ChangePasswordCard() {
const [visible, setVisible] = useState(false);
const [acceptable, setAcceptable] = useState(false);
const [assessment, setAssessment] = useState<PasswordAssessment | null>(null);
const [managedByIdp, setManagedByIdp] = useState<string | null>(null);
const [refusedByServer, setRefusedByServer] = useState<string | null>(null);
const hasPassword = useHasLocalPassword();
// The server's own refusal wins; otherwise what /auth/me said.
const managedByIdp = refusedByServer ?? (hasPassword.data === false ? '' : null);
const { feedback, flashSuccess } = useSuccessFeedback();

const {
Expand Down Expand Up @@ -107,7 +117,7 @@ export function ChangePasswordCard() {
setError('next', { message: body.error });
return;
case 'no_local_password':
setManagedByIdp(body.error ?? '');
setRefusedByServer(body.error ?? '');
return;
default:
if (isAxiosError(err) && err.response?.status === 429) {
Expand Down Expand Up @@ -138,10 +148,12 @@ export function ChangePasswordCard() {
<h3 id="change-password-title" className="text-[14px] font-semibold text-ink">
{t('accountSecurity.title')}
</h3>
{!managedByIdp && toggle}
{managedByIdp === null && !hasPassword.isLoading && toggle}
</div>

{managedByIdp !== null ? (
{hasPassword.isLoading && refusedByServer === null ? (
<SkeletonRows rows={3} height={36} />
) : managedByIdp !== null ? (
<p
className="text-[12.5px] text-ink-soft leading-relaxed"
data-testid="password-managed-by-idp"
Expand Down
58 changes: 55 additions & 3 deletions frontend/src/features/auth/__tests__/changePasswordCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,31 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render as rtlRender, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import userEvent from '@testing-library/user-event';

import { ChangePasswordCard } from '../ChangePasswordCard';
import { useUIStore } from '../../../store/uiStore';
import { getAccessToken, setAccessToken } from '../../../lib/session';

const render = (ui: React.ReactElement) => rtlRender(<MemoryRouter>{ui}</MemoryRouter>);
const render = (ui: React.ReactElement) =>
rtlRender(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);

const changePassword = vi.fn();
const fetchHasLocalPassword = vi.fn();
vi.mock('../authService', async () => {
const actual = await vi.importActual<typeof import('../authService')>('../authService');
return { ...actual, changePassword: (...a: unknown[]) => changePassword(...a) };
return {
...actual,
changePassword: (...a: unknown[]) => changePassword(...a),
fetchHasLocalPassword: (...a: unknown[]) => fetchHasLocalPassword(...a),
};
});

// The meter talks to the server and loads zxcvbn; here it only reports a verdict.
Expand All @@ -38,14 +51,16 @@ function axiosError(status: number, data: unknown) {
}

async function fill(current: string, next: string, confirm = next) {
await userEvent.type(screen.getByLabelText(/^Current password/), current);
await userEvent.type(await screen.findByLabelText(/^Current password/), current);
await userEvent.type(screen.getByLabelText(/^New password/), next);
await userEvent.type(screen.getByLabelText(/^Confirm the new password/), confirm);
await userEvent.click(screen.getByTestId('change-password-submit'));
}

beforeEach(() => {
changePassword.mockReset();
fetchHasLocalPassword.mockReset();
fetchHasLocalPassword.mockResolvedValue(true);
toastSuccess.mockReset();
useUIStore.getState().setLang('en');
});
Expand Down Expand Up @@ -105,4 +120,41 @@ describe('ChangePasswordCard', () => {
);
expect(screen.queryByTestId('change-password-submit')).not.toBeInTheDocument();
});

describe('an account without a password in OpenRisk (#850)', () => {
it('says the identity provider owns the password and offers no fields', async () => {
fetchHasLocalPassword.mockResolvedValue(false);
render(<ChangePasswordCard />);

expect(await screen.findByTestId('password-managed-by-idp')).toHaveTextContent(
/identity provider/i,
);
expect(screen.queryByLabelText(/^Current password/)).not.toBeInTheDocument();
expect(screen.queryByTestId('change-password-submit')).not.toBeInTheDocument();
expect(changePassword).not.toHaveBeenCalled();
});

it('shows a skeleton, not the form, while it finds out', () => {
fetchHasLocalPassword.mockReturnValue(new Promise(() => {}));
render(<ChangePasswordCard />);

expect(screen.queryByLabelText(/^Current password/)).not.toBeInTheDocument();
expect(screen.queryByTestId('password-managed-by-idp')).not.toBeInTheDocument();
});

it('falls back to the form when it cannot tell, and the server still corrects it', async () => {
fetchHasLocalPassword.mockRejectedValue(new Error('offline'));
changePassword.mockRejectedValue(
axiosError(409, { code: 'no_local_password', error: 'Managed by your identity provider.' }),
);
render(<ChangePasswordCard />);
// One retry (1 s) before the card gives up and shows the form.
await screen.findByLabelText(/^Current password/, {}, { timeout: 3000 });
await fill('anything-at-all', 'Violet-Kilimanjaro-Anchor-2026!');

expect(await screen.findByTestId('password-managed-by-idp')).toHaveTextContent(
'Managed by your identity provider.',
);
});
});
});
10 changes: 9 additions & 1 deletion frontend/src/features/auth/authService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,16 @@ export interface DisableMFAErrorBody {
export type DisableMFAProof = 'password' | 'code';

export async function fetchDisableMFAProof(): Promise<DisableMFAProof> {
return (await fetchHasLocalPassword()) === false ? 'code' : 'password';
}

/**
* Whether the signed-in account has a password in OpenRisk (#754, #850). False
* for an identity-provider account; null when the server did not say.
*/
export async function fetchHasLocalPassword(): Promise<boolean | null> {
const { data } = await api.get<{ has_password?: boolean }>('/auth/me');
return data?.has_password === false ? 'code' : 'password';
return typeof data?.has_password === 'boolean' ? data.has_password : null;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/features/auth/useHasLocalPassword.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) 2026 OpenDefender Contributors
// SPDX-License-Identifier: AGPL-3.0-only
//
// #850 — does this account have a password in OpenRisk at all?

import { useQuery } from '@tanstack/react-query';
import { fetchHasLocalPassword } from './authService';

/**
* Read fresh when a password surface mounts. `data` is null when the server did
* not say; callers treat that like "has a password" and let the server's
* no_local_password answer correct them.
*/
export function useHasLocalPassword() {
return useQuery({
queryKey: ['auth', 'has-local-password'],
queryFn: fetchHasLocalPassword,
staleTime: 0,
retry: 1,
});
}
Loading