feat(auth): migrate account security presentation
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
jest.unmock("./components/GoogleAuthCard");
|
||||
jest.unmock("./components/MicrosoftAuthCard");
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import GoogleAuthCard from "./components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "./components/MicrosoftAuthCard";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
|
||||
const mockMsalInitialize = jest.fn();
|
||||
const mockMsalLoginPopup = jest.fn();
|
||||
jest.mock("@azure/msal-browser", () => ({
|
||||
PublicClientApplication: jest.fn().mockImplementation(() => ({
|
||||
initialize: mockMsalInitialize,
|
||||
loginPopup: mockMsalLoginPopup,
|
||||
})),
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const linkedAt = "2026-01-14T12:00:00Z";
|
||||
const localAccount = { provider: "local", email: "person@example.test", userName: "Person" };
|
||||
const originalGoogleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
const originalMicrosoftClientId = process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID;
|
||||
const providers = [
|
||||
{ name: "Google", Card: GoogleAuthCard, title: "googleAccountTitle", hint: "googleSetupHint" },
|
||||
{ name: "Microsoft", Card: MicrosoftAuthCard, title: "microsoftAccountTitle", hint: "microsoftSetupHint" },
|
||||
] as const;
|
||||
|
||||
function mockAccount(me: unknown = localAccount) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === "/auth/me") return Promise.resolve({ data: me } as any);
|
||||
if (url === "/auth/config") return Promise.resolve({ data: { allowRegistration: false } } as any);
|
||||
return Promise.reject(new Error(`Unexpected account read: ${url}`));
|
||||
});
|
||||
}
|
||||
|
||||
function renderAccount(Card: React.ComponentType, language: "en" | "nb" = "en") {
|
||||
localStorage.setItem("uiLanguage", language);
|
||||
return render(<ToastProvider><I18nProvider><Card /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
async function click(element: Element) {
|
||||
await act(async () => { fireEvent.click(element); });
|
||||
}
|
||||
|
||||
function installGoogleIdentity() {
|
||||
let callback: ((response: { credential?: string }) => void) | undefined;
|
||||
const initialize = jest.fn((options: { callback: (response: { credential?: string }) => void }) => {
|
||||
callback = options.callback;
|
||||
});
|
||||
window.google = { accounts: { id: {
|
||||
initialize,
|
||||
renderButton: jest.fn((host: HTMLElement) => {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = "Mock Google credential action";
|
||||
button.onclick = () => callback?.({ credential: "unit-google-credential" });
|
||||
host.appendChild(button);
|
||||
}),
|
||||
} } };
|
||||
return initialize;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("authUserKey", "local-account-fixture");
|
||||
mockedApi.get.mockReset();
|
||||
mockedApi.post.mockReset();
|
||||
mockedApi.delete.mockReset();
|
||||
mockedApi.put.mockClear();
|
||||
mockedApi.patch.mockClear();
|
||||
mockMsalInitialize.mockReset().mockResolvedValue(undefined);
|
||||
mockMsalLoginPopup.mockReset();
|
||||
delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID;
|
||||
delete window.google;
|
||||
mockAccount();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.google;
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalGoogleClientId === undefined) delete process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
|
||||
else process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = originalGoogleClientId;
|
||||
if (originalMicrosoftClientId === undefined) delete process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID;
|
||||
else process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = originalMicrosoftClientId;
|
||||
});
|
||||
|
||||
describe("Profile account provider cards", () => {
|
||||
test.each(providers)("[PRES-E02/E09] disabled $name has a named account section without starting OAuth", async ({ Card, title, hint }) => {
|
||||
renderAccount(Card, "nb");
|
||||
expect(await screen.findByText(translations.nb[hint])).toBeInTheDocument();
|
||||
expect(screen.queryByText(/NEXT_PUBLIC_/)).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me"));
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(document.querySelector('script[src^="https://accounts.google.com/"]')).not.toBeInTheDocument();
|
||||
expect(mockMsalInitialize).not.toHaveBeenCalled();
|
||||
expect(mockMsalLoginPopup).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("heading", { level: 2, name: translations.nb[title] })).toBeInTheDocument();
|
||||
expect(screen.getByRole("region", { name: translations.nb[title] })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("[PRES-E02/E09] Google linked metadata follows Bokmål while retaining the stored timestamp", async () => {
|
||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client";
|
||||
installGoogleIdentity();
|
||||
mockAccount({ ...localAccount, googleLink: { linked: true, email: localAccount.email, linkedAt } });
|
||||
renderAccount(GoogleAuthCard, "nb");
|
||||
await screen.findByRole("button", { name: translations.nb.unlinkGoogle });
|
||||
expect(screen.getByText(translations.nb.googleLinkedDate.replace("{date}", new Intl.DateTimeFormat("nb-NO").format(new Date(linkedAt))))).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E03/E09] Microsoft linked metadata follows Bokmål while retaining the stored timestamp", async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client";
|
||||
mockAccount({ ...localAccount, microsoftLink: { linked: true, email: localAccount.email, linkedAt } });
|
||||
renderAccount(MicrosoftAuthCard, "nb");
|
||||
await screen.findByRole("button", { name: translations.nb.unlinkMicrosoft });
|
||||
expect(screen.getByText(translations.nb.microsoftLinkedDate.replace("{date}", new Intl.DateTimeFormat("nb-NO").format(new Date(linkedAt))))).toBeInTheDocument();
|
||||
expect(mockMsalLoginPopup).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E03/E08] Microsoft local linking requires the current password and cancellation keeps it without exchanging credentials", async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client";
|
||||
mockMsalLoginPopup.mockRejectedValue({ errorCode: "user_cancelled", message: "Provider sign-in was cancelled." });
|
||||
renderAccount(MicrosoftAuthCard);
|
||||
const link = await screen.findByRole("button", { name: translations.en.linkWithMicrosoft });
|
||||
const password = screen.getByLabelText(translations.en.profileCurrentPassword);
|
||||
expect(link).toBeDisabled();
|
||||
await click(link);
|
||||
expect(mockMsalLoginPopup).not.toHaveBeenCalled();
|
||||
fireEvent.change(password, { target: { value: "unit-current-password" } });
|
||||
expect(link).toBeEnabled();
|
||||
await click(link);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Provider sign-in was cancelled.");
|
||||
expect(password).toHaveValue("unit-current-password");
|
||||
expect(link).toBeEnabled();
|
||||
expect(mockMsalInitialize).toHaveBeenCalledTimes(1);
|
||||
expect(mockMsalLoginPopup).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture");
|
||||
});
|
||||
|
||||
test("[PRES-E03/E08] rejected Microsoft linking retains the password and local account for an explicit retry", async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client";
|
||||
mockMsalLoginPopup.mockResolvedValue({ idToken: "unit-microsoft-credential" });
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { data: "Current password was not accepted." } });
|
||||
renderAccount(MicrosoftAuthCard);
|
||||
const link = await screen.findByRole("button", { name: translations.en.linkWithMicrosoft });
|
||||
const password = screen.getByLabelText(translations.en.profileCurrentPassword);
|
||||
fireEvent.change(password, { target: { value: "unit-current-password" } });
|
||||
await click(link);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Current password was not accepted.");
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/auth/microsoft/link", { token: "unit-microsoft-credential", currentPassword: "unit-current-password" });
|
||||
expect(password).toHaveValue("unit-current-password");
|
||||
expect(link).toBeEnabled();
|
||||
expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture");
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E03/E05] successful Microsoft local linking retains the established forced sign-in contract", async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client";
|
||||
mockMsalLoginPopup.mockResolvedValue({ idToken: "unit-microsoft-credential" });
|
||||
mockedApi.post.mockImplementation((url: string) => {
|
||||
if (url !== "/auth/microsoft/link") return Promise.reject(new Error(`Unexpected account write: ${url}`));
|
||||
mockAccount(null);
|
||||
return Promise.resolve({ data: { linked: true } } as any);
|
||||
});
|
||||
renderAccount(MicrosoftAuthCard);
|
||||
const link = await screen.findByRole("button", { name: translations.en.linkWithMicrosoft });
|
||||
fireEvent.change(screen.getByLabelText(translations.en.profileCurrentPassword), { target: { value: "unit-current-password" } });
|
||||
await click(link);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(translations.en.microsoftSecurityChangeSignInAgain);
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/auth/microsoft/link", { token: "unit-microsoft-credential", currentPassword: "unit-current-password" });
|
||||
expect(localStorage.getItem("authUserKey")).toBeNull();
|
||||
expect(screen.queryByLabelText(translations.en.profileCurrentPassword)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: translations.en.continueWithMicrosoft })).toBeEnabled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E03/E05/E08] Microsoft unlink is password-guarded and a failed attempt retains input until a deliberate retry succeeds", async () => {
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client";
|
||||
mockAccount({ ...localAccount, microsoftLink: { linked: true, email: localAccount.email, linkedAt } });
|
||||
mockedApi.delete.mockRejectedValueOnce({ response: { data: "Unlink unavailable." } }).mockImplementationOnce(() => {
|
||||
mockAccount(null);
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
renderAccount(MicrosoftAuthCard);
|
||||
const unlink = await screen.findByRole("button", { name: translations.en.unlinkMicrosoft });
|
||||
const password = screen.getByLabelText(translations.en.profileCurrentPassword);
|
||||
expect(unlink).toBeDisabled();
|
||||
await click(unlink);
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
fireEvent.change(password, { target: { value: "unit-current-password" } });
|
||||
await click(unlink);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Unlink unavailable.");
|
||||
expect(password).toHaveValue("unit-current-password");
|
||||
expect(unlink).toBeEnabled();
|
||||
expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture");
|
||||
expect(mockedApi.delete).toHaveBeenNthCalledWith(1, "/auth/microsoft/link", { data: { currentPassword: "unit-current-password" } });
|
||||
await click(unlink);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: translations.en.unlinkMicrosoft })).not.toBeInTheDocument());
|
||||
expect(mockedApi.delete).toHaveBeenNthCalledWith(2, "/auth/microsoft/link", { data: { currentPassword: "unit-current-password" } });
|
||||
expect(mockedApi.delete).toHaveBeenCalledTimes(2);
|
||||
expect(localStorage.getItem("authUserKey")).toBeNull();
|
||||
expect(screen.queryByLabelText(translations.en.profileCurrentPassword)).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.each(["google", "microsoft"] as const)("[PRES-E02/E03] a %s-owned account cannot unlink its only authentication provider", async (provider) => {
|
||||
const google = provider === "google";
|
||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client";
|
||||
process.env.NEXT_PUBLIC_MICROSOFT_CLIENT_ID = "unit-microsoft-client";
|
||||
installGoogleIdentity();
|
||||
mockAccount({ ...localAccount, provider, [google ? "googleLink" : "microsoftLink"]: { linked: true, email: localAccount.email, linkedAt } });
|
||||
renderAccount(google ? GoogleAuthCard : MicrosoftAuthCard);
|
||||
await screen.findByText(google ? translations.en.googleExchangeHint : translations.en.microsoftExchangeHint);
|
||||
expect(screen.queryByRole("button", { name: google ? translations.en.unlinkGoogle : translations.en.unlinkMicrosoft })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(translations.en.profileCurrentPassword)).not.toBeInTheDocument();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E02/E08] Google local linking reports failure and retries the link endpoint without an exchange", async () => {
|
||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client";
|
||||
const initialize = installGoogleIdentity();
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { data: "Link unavailable." } }).mockImplementationOnce(() => {
|
||||
mockAccount({ ...localAccount, googleLink: { linked: true, email: localAccount.email, linkedAt } });
|
||||
return Promise.resolve({ data: { linked: true } } as any);
|
||||
});
|
||||
renderAccount(GoogleAuthCard);
|
||||
await screen.findByText(translations.en.googleBindHint);
|
||||
await click(await screen.findByRole("button", { name: "Mock Google credential action" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Link unavailable.");
|
||||
expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture");
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(1, "/auth/google/link", { token: "unit-google-credential", rememberMe: true });
|
||||
await click(screen.getByRole("button", { name: "Mock Google credential action" }));
|
||||
expect(await screen.findByRole("button", { name: translations.en.unlinkGoogle })).toBeEnabled();
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(2, "/auth/google/link", { token: "unit-google-credential", rememberMe: true });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(2);
|
||||
expect(initialize).toHaveBeenCalledTimes(1);
|
||||
expect(document.querySelector('script[src^="https://accounts.google.com/"]')).not.toBeInTheDocument();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E02/E08] failed Google unlink preserves the local account and linked state until explicit retry", async () => {
|
||||
process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID = "unit-google-client";
|
||||
installGoogleIdentity();
|
||||
mockAccount({ ...localAccount, googleLink: { linked: true, email: localAccount.email, linkedAt } });
|
||||
mockedApi.delete.mockRejectedValueOnce({ response: { data: "Unlink unavailable." } }).mockImplementationOnce(() => {
|
||||
mockAccount({ ...localAccount, googleLink: { linked: false } });
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
renderAccount(GoogleAuthCard);
|
||||
const unlink = await screen.findByRole("button", { name: translations.en.unlinkGoogle });
|
||||
await click(unlink);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Unlink unavailable.");
|
||||
expect(unlink).toBeEnabled();
|
||||
expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture");
|
||||
expect(mockedApi.delete).toHaveBeenNthCalledWith(1, "/auth/google/link");
|
||||
await click(unlink);
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: translations.en.unlinkGoogle })).not.toBeInTheDocument());
|
||||
expect(mockedApi.delete).toHaveBeenNthCalledWith(2, "/auth/google/link");
|
||||
expect(mockedApi.delete).toHaveBeenCalledTimes(2);
|
||||
expect(localStorage.getItem("authUserKey")).toBe("local-account-fixture");
|
||||
expect(screen.getByRole("button", { name: "Mock Google credential action" })).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import SessionsSettingsCard from "./components/SessionsSettingsCard";
|
||||
import TwoFactorSettingsCard from "./components/TwoFactorSettingsCard";
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const enabledStatus = { enabled: true, enabledAtUtc: "2026-02-10T12:00:00Z" };
|
||||
const currentSession = {
|
||||
id: "current-session", deviceLabel: "Local current session", isCurrentSession: true,
|
||||
createdAtUtc: "2026-02-10T12:00:00Z", lastSeenAtUtc: "2026-02-11T12:00:00Z",
|
||||
expiresAtUtc: "2026-03-10T12:00:00Z",
|
||||
};
|
||||
const otherSession = { ...currentSession, id: "other-session", deviceLabel: "Local other session", isCurrentSession: false };
|
||||
const writeText = jest.fn(() => Promise.resolve());
|
||||
const createObjectURL = jest.fn((_blob: Blob) => "blob:local-recovery-codes");
|
||||
const revokeObjectURL = jest.fn();
|
||||
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard");
|
||||
const createUrlDescriptor = Object.getOwnPropertyDescriptor(window.URL, "createObjectURL");
|
||||
const revokeUrlDescriptor = Object.getOwnPropertyDescriptor(window.URL, "revokeObjectURL");
|
||||
|
||||
function restoreProperty(target: object, key: string, descriptor?: PropertyDescriptor) {
|
||||
if (descriptor) Object.defineProperty(target, key, descriptor);
|
||||
else Reflect.deleteProperty(target, key);
|
||||
}
|
||||
|
||||
function renderCard(Card: React.ComponentType, language: "en" | "nb" = "en") {
|
||||
localStorage.setItem("uiLanguage", language);
|
||||
return render(<ToastProvider><I18nProvider><Card /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
function expectNoWrites() {
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
function readBlob(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||
Object.defineProperty(window.URL, "createObjectURL", { configurable: true, value: createObjectURL });
|
||||
Object.defineProperty(window.URL, "revokeObjectURL", { configurable: true, value: revokeObjectURL });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
mockedApi.get.mockReset().mockImplementation((url: string) => {
|
||||
if (url === "/auth/2fa/status") return Promise.resolve({ data: { enabled: false, enabledAtUtc: null } } as any);
|
||||
if (url === "/auth/2fa/trusted-devices" || url === "/auth/sessions") return Promise.resolve({ data: [] } as any);
|
||||
return Promise.reject(new Error(`Unexpected read: ${url}`));
|
||||
});
|
||||
for (const method of [mockedApi.post, mockedApi.delete, mockedApi.put, mockedApi.patch]) {
|
||||
method.mockReset().mockRejectedValue(new Error("Unexpected security write"));
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreProperty(navigator, "clipboard", clipboardDescriptor);
|
||||
restoreProperty(window.URL, "createObjectURL", createUrlDescriptor);
|
||||
restoreProperty(window.URL, "revokeObjectURL", revokeUrlDescriptor);
|
||||
});
|
||||
|
||||
test("cancelling setup sends no security write and clears the password before reopening", async () => {
|
||||
renderCard(TwoFactorSettingsCard);
|
||||
fireEvent.click(await screen.findByRole("button", { name: translations.en.twoFactorEnableButton }));
|
||||
const dialog = screen.getByRole("dialog", { name: translations.en.twoFactorPasswordPrompt });
|
||||
fireEvent.change(within(dialog).getByLabelText(translations.en.twoFactorPasswordLabel), { target: { value: "local-cancelled-password" } });
|
||||
expect(within(dialog).getByRole("button", { name: translations.en.twoFactorContinue })).toBeEnabled();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.cancel }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expectNoWrites();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: translations.en.twoFactorEnableButton }));
|
||||
const reopened = screen.getByRole("dialog", { name: translations.en.twoFactorPasswordPrompt });
|
||||
expect(within(reopened).getByLabelText(translations.en.twoFactorPasswordLabel)).toHaveValue("");
|
||||
expect(within(reopened).getByRole("button", { name: translations.en.twoFactorContinue })).toBeDisabled();
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test("regenerated recovery codes require acknowledgement, survive Escape, export correctly, and clear after Done", async () => {
|
||||
const codes = ["local-11111", "local-22222"];
|
||||
mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: url === "/auth/2fa/status" ? enabledStatus : [] } as any));
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { recoveryCodes: codes } } as any);
|
||||
let downloadedLink: { href: string; filename: string } | undefined;
|
||||
jest.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (this: HTMLAnchorElement) {
|
||||
downloadedLink = { href: this.href, filename: this.download };
|
||||
});
|
||||
renderCard(TwoFactorSettingsCard);
|
||||
fireEvent.click(await screen.findByRole("button", { name: translations.en.twoFactorRegenerateButton }));
|
||||
const passwordDialog = screen.getByRole("dialog", { name: translations.en.twoFactorPasswordPrompt });
|
||||
fireEvent.change(within(passwordDialog).getByLabelText(translations.en.twoFactorPasswordLabel), { target: { value: "local-regeneration-password" } });
|
||||
fireEvent.click(within(passwordDialog).getByRole("button", { name: translations.en.twoFactorContinue }));
|
||||
const recoveryDialog = await screen.findByRole("dialog", { name: translations.en.twoFactorRecoveryTitle });
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/auth/2fa/recovery-codes/regenerate", { currentPassword: "local-regeneration-password" });
|
||||
for (const code of codes) expect(within(recoveryDialog).getByText(code)).toBeVisible();
|
||||
const acknowledgement = within(recoveryDialog).getByRole("checkbox", { name: translations.en.twoFactorSavedConfirm });
|
||||
const done = within(recoveryDialog).getByRole("button", { name: translations.en.twoFactorDone });
|
||||
expect(acknowledgement).not.toBeChecked();
|
||||
expect(done).toBeDisabled();
|
||||
fireEvent.keyDown(recoveryDialog, { key: "Escape", code: "Escape", keyCode: 27 });
|
||||
fireEvent.click(done);
|
||||
expect(screen.getByRole("dialog", { name: translations.en.twoFactorRecoveryTitle })).toBeVisible();
|
||||
|
||||
fireEvent.click(within(recoveryDialog).getByRole("button", { name: translations.en.twoFactorCopyAll }));
|
||||
expect(writeText).toHaveBeenCalledWith(codes.join("\n"));
|
||||
fireEvent.click(within(recoveryDialog).getByRole("button", { name: translations.en.twoFactorDownload }));
|
||||
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||||
const blob = createObjectURL.mock.calls[0][0];
|
||||
expect(blob.type).toBe("text/plain");
|
||||
expect(await readBlob(blob)).toBe(`${codes.join("\n")}\n`);
|
||||
expect(downloadedLink).toEqual({ href: "blob:local-recovery-codes", filename: "jobbjakt-recovery-codes.txt" });
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:local-recovery-codes");
|
||||
expect(document.querySelector('a[download="jobbjakt-recovery-codes.txt"]')).toBeNull();
|
||||
|
||||
fireEvent.click(acknowledgement);
|
||||
expect(done).toBeEnabled();
|
||||
fireEvent.click(done);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
for (const code of codes) {
|
||||
expect(document.body).not.toHaveTextContent(code);
|
||||
expect(JSON.stringify({ ...localStorage, ...sessionStorage })).not.toContain(code);
|
||||
}
|
||||
fireEvent.click(await screen.findByRole("button", { name: translations.en.twoFactorRegenerateButton }));
|
||||
expect(screen.getByLabelText(translations.en.twoFactorPasswordLabel)).toHaveValue("");
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("Bokmål enabled status and trusted-device dates survive a read-only retry, then revoke the selected device with readback", async () => {
|
||||
const device = {
|
||||
id: 17, deviceLabel: "Local trusted device", isCurrentDevice: false,
|
||||
createdAtUtc: "2026-02-10T12:00:00Z", lastSeenAtUtc: "2026-02-11T12:00:00Z",
|
||||
expiresAtUtc: "2026-03-10T12:00:00Z",
|
||||
};
|
||||
let devices = [device];
|
||||
let deviceReads = 0;
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === "/auth/2fa/status") return Promise.resolve({ data: enabledStatus } as any);
|
||||
if (url === "/auth/2fa/trusted-devices") {
|
||||
if (++deviceReads === 1) return Promise.reject({ response: { status: 503, data: "Trusted devices unavailable" } });
|
||||
return Promise.resolve({ data: devices } as any);
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected read: ${url}`));
|
||||
});
|
||||
mockedApi.delete.mockImplementation((url: string) => {
|
||||
if (url !== "/auth/2fa/trusted-devices/17") return Promise.reject(new Error(`Unexpected delete: ${url}`));
|
||||
devices = [];
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
renderCard(TwoFactorSettingsCard, "nb");
|
||||
const region = screen.getByRole("region", { name: translations.nb.twoFactorSectionTitle });
|
||||
expect(await within(region).findByText(translations.nb.twoFactorStatusEnabled.replace("{date}", new Date(enabledStatus.enabledAtUtc).toLocaleDateString("nb-NO")))).toBeVisible();
|
||||
expect(within(region).getByRole("heading", { level: 3, name: translations.nb.twoFactorTrustedDevicesTitle })).toBeInTheDocument();
|
||||
const alert = await within(region).findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Trusted devices unavailable");
|
||||
expect(within(region).queryByText(translations.nb.twoFactorTrustedDevicesEmpty)).not.toBeInTheDocument();
|
||||
expectNoWrites();
|
||||
fireEvent.click(within(alert).getByRole("button", { name: translations.nb.retry }));
|
||||
expect(await within(region).findByText(device.deviceLabel)).toBeVisible();
|
||||
const metadata = translations.nb.twoFactorTrustedDeviceMeta
|
||||
.replace("{lastSeen}", new Date(device.lastSeenAtUtc).toLocaleDateString("nb-NO"))
|
||||
.replace("{expires}", new Date(device.expiresAtUtc).toLocaleDateString("nb-NO"));
|
||||
expect(within(region).getByText(metadata)).toBeVisible();
|
||||
expect(deviceReads).toBe(2);
|
||||
expectNoWrites();
|
||||
|
||||
fireEvent.click(within(region).getByRole("button", { name: translations.nb.twoFactorRevokeDevice }));
|
||||
expect(await within(region).findByText(translations.nb.twoFactorTrustedDevicesEmpty)).toBeVisible();
|
||||
expect(within(region).queryByText(device.deviceLabel)).not.toBeInTheDocument();
|
||||
expect(mockedApi.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.delete).toHaveBeenCalledWith("/auth/2fa/trusted-devices/17");
|
||||
expect(deviceReads).toBe(3);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("a failed sessions read offers a read-only retry without inventing an empty session list", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 503, data: "Sessions unavailable" } })
|
||||
.mockResolvedValueOnce({ data: [currentSession] } as any);
|
||||
renderCard(SessionsSettingsCard);
|
||||
const region = screen.getByRole("region", { name: translations.en.sessionsSectionTitle });
|
||||
const alert = await within(region).findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Sessions unavailable");
|
||||
expect(within(region).queryByText(translations.en.sessionsEmpty)).not.toBeInTheDocument();
|
||||
expectNoWrites();
|
||||
fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry }));
|
||||
expect(await within(region).findByText(currentSession.deviceLabel)).toBeVisible();
|
||||
expect(within(region).queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(1, "/auth/sessions");
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/auth/sessions");
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test("pending revoke-others disables confirmation and cancellation, blocks duplicate writes, and reads back the surviving session", async () => {
|
||||
let sessions = [currentSession, otherSession];
|
||||
let resolveRevoke!: (value: any) => void;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/auth/sessions"
|
||||
? Promise.resolve({ data: sessions } as any)
|
||||
: Promise.reject(new Error(`Unexpected read: ${url}`)));
|
||||
mockedApi.post.mockImplementation(() => new Promise(resolve => { resolveRevoke = resolve; }));
|
||||
renderCard(SessionsSettingsCard);
|
||||
fireEvent.click(await screen.findByRole("button", { name: translations.en.sessionsRevokeOthers }));
|
||||
const dialog = screen.getByRole("dialog", { name: translations.en.sessionsRevokeOthersConfirmTitle });
|
||||
const confirm = within(dialog).getByRole("button", { name: translations.en.sessionsRevokeOthers });
|
||||
const cancel = within(dialog).getByRole("button", { name: translations.en.cancel });
|
||||
fireEvent.click(confirm);
|
||||
expect(confirm).toBeDisabled();
|
||||
expect(cancel).toBeDisabled();
|
||||
fireEvent.click(confirm);
|
||||
fireEvent.click(cancel);
|
||||
fireEvent.keyDown(dialog, { key: "Escape", code: "Escape", keyCode: 27 });
|
||||
expect(screen.getByRole("dialog", { name: translations.en.sessionsRevokeOthersConfirmTitle })).toBeVisible();
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/auth/sessions/revoke-others");
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
|
||||
sessions = [currentSession];
|
||||
await act(async () => resolveRevoke({ data: {} }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByText(currentSession.deviceLabel)).toBeVisible();
|
||||
expect(screen.queryByText(otherSession.deviceLabel)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: translations.en.sessionsRevokeOthers })).not.toBeInTheDocument();
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.get).toHaveBeenNthCalledWith(2, "/auth/sessions");
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ToastProvider } from "./toast";
|
||||
import AuthStatusCard from "./components/AuthStatusCard";
|
||||
import SessionsSettingsCard from "./components/SessionsSettingsCard";
|
||||
import TwoFactorSettingsCard from "./components/TwoFactorSettingsCard";
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const session = {
|
||||
id: "current", deviceLabel: "Current device", isCurrentSession: true,
|
||||
createdAtUtc: "2026-02-10T12:00:00Z", lastSeenAtUtc: "2026-02-10T12:00:00Z",
|
||||
expiresAtUtc: "2026-03-10T12:00:00Z",
|
||||
};
|
||||
|
||||
function renderCard(Card: React.ComponentType, language: "en" | "nb" = "en") {
|
||||
localStorage.setItem("uiLanguage", language);
|
||||
return render(<ToastProvider><I18nProvider><Card /></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
test("[PRES-E02/E09] Bokmål signed-in status and section semantics use the UI language", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { userName: "Demo", provider: "local" } } as any);
|
||||
renderCard(AuthStatusCard, "nb");
|
||||
expect(await screen.findByText(/Demo/)).toHaveTextContent("Logget inn som Demo");
|
||||
expect(screen.getByRole("region", { name: translations.nb.authStatusTitle })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 2, name: translations.nb.authStatusTitle })).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E02/E08] a failed identity read is unknown and retries without a logout or login write", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 500, data: "Identity unavailable" } })
|
||||
.mockResolvedValueOnce({ data: { userName: "Recovered", provider: "local" } } as any);
|
||||
renderCard(AuthStatusCard);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Identity unavailable");
|
||||
expect(screen.queryByText(translations.en.authStatusNotSignedIn)).not.toBeInTheDocument();
|
||||
fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry }));
|
||||
expect(await screen.findByText(/Signed in as Recovered/)).toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E02] an authoritative unauthenticated identity read retains signed-out presentation", async () => {
|
||||
mockedApi.get.mockRejectedValueOnce({ response: { status: 401 } });
|
||||
renderCard(AuthStatusCard);
|
||||
expect(await screen.findByText(translations.en.authStatusNotSignedIn)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E04] unknown 2FA status announces loading and cannot suggest setup", async () => {
|
||||
let resolveStatus!: (value: any) => void;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/auth/2fa/status"
|
||||
? new Promise(resolve => { resolveStatus = resolve; })
|
||||
: Promise.resolve({ data: [] } as any));
|
||||
renderCard(TwoFactorSettingsCard);
|
||||
expect(screen.getByRole("status")).toHaveTextContent(translations.en.loading);
|
||||
expect(screen.queryByRole("button", { name: translations.en.twoFactorEnableButton })).not.toBeInTheDocument();
|
||||
await act(async () => resolveStatus({ data: { enabled: false, enabledAtUtc: null } }));
|
||||
expect(await screen.findByRole("button", { name: translations.en.twoFactorEnableButton })).toBeEnabled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E04/E08] failed 2FA status offers read-only retry before security actions", async () => {
|
||||
let attempts = 0;
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url !== "/auth/2fa/status") return Promise.resolve({ data: [] } as any);
|
||||
if (++attempts === 1) return Promise.reject({ response: { data: "Status unavailable" } });
|
||||
return Promise.resolve({ data: { enabled: true, enabledAtUtc: "2026-02-10T12:00:00Z" } } as any);
|
||||
});
|
||||
renderCard(TwoFactorSettingsCard);
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Status unavailable");
|
||||
expect(screen.queryByRole("button", { name: translations.en.twoFactorEnableButton })).not.toBeInTheDocument();
|
||||
fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry }));
|
||||
expect(await screen.findByRole("button", { name: translations.en.twoFactorDisableButton })).toBeEnabled();
|
||||
expect(attempts).toBe(2);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("[PRES-E05/E08] session-revocation failure stays inside its named confirmation for retry", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [session, { ...session, id: "other", isCurrentSession: false }] } as any);
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { data: "Revocation unavailable" } }).mockResolvedValueOnce({ data: {} } as any);
|
||||
renderCard(SessionsSettingsCard);
|
||||
fireEvent.click(await screen.findByRole("button", { name: translations.en.sessionsRevokeOthers }));
|
||||
const dialog = screen.getByRole("dialog", { name: translations.en.sessionsRevokeOthersConfirmTitle });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.sessionsRevokeOthers }));
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent("Revocation unavailable");
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(1, "/auth/sessions/revoke-others");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.sessionsRevokeOthers }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(mockedApi.post).toHaveBeenNthCalledWith(2, "/auth/sessions/revoke-others");
|
||||
});
|
||||
|
||||
test("[PRES-E05/E09] sessions keep stored timestamps and render dates in Bokmål", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: [session] } as any);
|
||||
renderCard(SessionsSettingsCard, "nb");
|
||||
expect(await screen.findByText(/Sist aktiv/)).toHaveTextContent(
|
||||
new Date(session.lastSeenAtUtc).toLocaleString("nb-NO"));
|
||||
expect(screen.getByText(/Sist aktiv/)).toHaveTextContent(
|
||||
new Date(session.expiresAtUtc).toLocaleDateString("nb-NO"));
|
||||
expect(screen.getByRole("region", { name: translations.nb.sessionsSectionTitle })).toBeInTheDocument();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, Paper, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Typography } from "@mui/material";
|
||||
|
||||
import { api } from "../api";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthClientState } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type MeResponse = {
|
||||
provider?: string;
|
||||
@@ -26,34 +27,40 @@ export default function AuthStatusCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.get<MeResponse>("/auth/me")
|
||||
.then((r) => setMe(r.data))
|
||||
.catch((e) => {
|
||||
setMe(null);
|
||||
if (e?.response?.status !== 401) setError(getApiErrorMessage(e, t("profileLoadFailed")));
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => {
|
||||
api
|
||||
.get<MeResponse>("/auth/me")
|
||||
.then((r) => setMe(r.data))
|
||||
.catch(() => setMe(null));
|
||||
};
|
||||
|
||||
refresh();
|
||||
window.addEventListener("auth-changed", refresh);
|
||||
return () => window.removeEventListener("auth-changed", refresh);
|
||||
}, []);
|
||||
}, [refresh]);
|
||||
|
||||
const label = useMemo(() => me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email, [me]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("authStatusTitle")}
|
||||
</Typography>
|
||||
|
||||
{!me ? (
|
||||
<PageSection title={t("authStatusTitle")} surface>
|
||||
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : error ? (
|
||||
<Alert severity="error" action={<Button color="inherit" onClick={refresh}>{t("retry")}</Button>}>{error}</Alert>
|
||||
) : !me ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>{t("authStatusNotSignedIn")}</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5 }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.5, overflowWrap: "anywhere" }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
Signed in{label ? ` as ${label}` : ""}{me?.provider ? ` (${me.provider})` : ""}.
|
||||
{label ? t("signedInAs", { name: label }) : t("signedIn")}
|
||||
{me.provider ? ` (${me.provider === "local" ? t("profileLocalAccount") : me.provider === "google" ? t("profileGoogleSession") : t("profileExternalSession")})` : ""}
|
||||
</Typography>
|
||||
{me?.roles && me.roles.length > 0 ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
@@ -82,6 +89,6 @@ export default function AuthStatusCard() {
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||
import { Box, Button, Chip, Typography } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
|
||||
import TwoFactorChallenge from "./TwoFactorChallenge";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -77,7 +78,7 @@ function loadGoogleScript(): Promise<void> {
|
||||
|
||||
export default function GoogleAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||
@@ -197,11 +198,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("googleAccountTitle")}
|
||||
</Typography>
|
||||
|
||||
<PageSection title={t("googleAccountTitle")} surface>
|
||||
{!clientId && (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("googleSetupHint")}
|
||||
@@ -225,7 +222,7 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={me?.googleLink?.linked ? t("googleLinked") : t("googleAvailableToLink")} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||
{me?.googleLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("googleLinkedDate", { date: new Date(me.googleLink.linkedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} /> : null}
|
||||
</Box>
|
||||
|
||||
{!signedIn ? (
|
||||
@@ -295,6 +292,6 @@ export default function GoogleAuthCard({ onSignedIn, presentation = "account" }:
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Chip, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Box, Button, Chip, TextField, Typography } from "@mui/material";
|
||||
import { PublicClientApplication } from "@azure/msal-browser";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
@@ -8,6 +8,7 @@ import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
|
||||
import TwoFactorChallenge from "./TwoFactorChallenge";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "microsoft" | "external";
|
||||
@@ -34,7 +35,7 @@ export function getMicrosoftMsalInstance(clientId: string): PublicClientApplicat
|
||||
|
||||
export default function MicrosoftAuthCard({ onSignedIn, presentation = "account" }: { onSignedIn?: () => void; presentation?: "account" | "sign-in" }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [pendingToken, setPendingToken] = useState<string | null>(null);
|
||||
@@ -128,11 +129,7 @@ export default function MicrosoftAuthCard({ onSignedIn, presentation = "account"
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("microsoftAccountTitle")}
|
||||
</Typography>
|
||||
|
||||
<PageSection title={t("microsoftAccountTitle")} surface>
|
||||
{!clientId && (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftSetupHint")}
|
||||
@@ -156,7 +153,7 @@ export default function MicrosoftAuthCard({ onSignedIn, presentation = "account"
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
|
||||
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString(language === "nb" ? "nb-NO" : "en") })} /> : null}
|
||||
</Box>
|
||||
|
||||
{!signedIn ? (
|
||||
@@ -241,6 +238,6 @@ export default function MicrosoftAuthCard({ onSignedIn, presentation = "account"
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
@@ -21,6 +20,7 @@ import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { clearAuthClientState } from "../auth";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type Session = {
|
||||
id: string;
|
||||
@@ -37,11 +37,14 @@ function apiErrorMessage(e: any, t: (k: any) => string) {
|
||||
|
||||
export default function SessionsSettingsCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const locale = language === "nb" ? "nb-NO" : "en";
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRevokeOthers, setConfirmRevokeOthers] = useState(false);
|
||||
const [confirmError, setConfirmError] = useState<string | null>(null);
|
||||
const [revokingOthers, setRevokingOthers] = useState(false);
|
||||
|
||||
const loadSessions = useCallback(() => {
|
||||
setLoading(true);
|
||||
@@ -73,23 +76,29 @@ export default function SessionsSettingsCard() {
|
||||
}
|
||||
|
||||
async function revokeOthers() {
|
||||
setRevokingOthers(true);
|
||||
setConfirmError(null);
|
||||
try {
|
||||
await api.post("/auth/sessions/revoke-others");
|
||||
toast(t("sessionsRevokedOthers"), "success");
|
||||
setConfirmRevokeOthers(false);
|
||||
loadSessions();
|
||||
} catch (e: any) {
|
||||
setError(apiErrorMessage(e, t));
|
||||
setConfirmError(apiErrorMessage(e, t));
|
||||
} finally {
|
||||
setRevokingOthers(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("sessionsSectionTitle")}
|
||||
</Typography>
|
||||
function closeConfirmation() {
|
||||
setConfirmRevokeOthers(false);
|
||||
setConfirmError(null);
|
||||
}
|
||||
|
||||
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{error}</Alert> : null}
|
||||
return (
|
||||
<PageSection title={t("sessionsSectionTitle")} surface>
|
||||
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
{error ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadSessions}>{t("retry")}</Button>}>{error}</Alert> : null}
|
||||
{!loading && sessions.length === 0 && !error ? (
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("sessionsEmpty")}</Typography>
|
||||
) : null}
|
||||
@@ -99,6 +108,7 @@ export default function SessionsSettingsCard() {
|
||||
{sessions.map((s) => (
|
||||
<ListItem key={s.id} divider>
|
||||
<ListItemText
|
||||
sx={{ minWidth: 0, overflowWrap: "anywhere" }}
|
||||
primary={
|
||||
<>
|
||||
{s.deviceLabel || t("sessionsUnknownDevice")}
|
||||
@@ -110,8 +120,8 @@ export default function SessionsSettingsCard() {
|
||||
</>
|
||||
}
|
||||
secondary={t("sessionsMeta", {
|
||||
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(),
|
||||
expires: new Date(s.expiresAtUtc).toLocaleDateString(),
|
||||
lastSeen: new Date(s.lastSeenAtUtc).toLocaleString(locale),
|
||||
expires: new Date(s.expiresAtUtc).toLocaleDateString(locale),
|
||||
})}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
@@ -125,23 +135,24 @@ export default function SessionsSettingsCard() {
|
||||
) : null}
|
||||
|
||||
{sessions.length > 1 ? (
|
||||
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => setConfirmRevokeOthers(true)}>
|
||||
<Button variant="text" color="warning" size="small" sx={{ mt: 1 }} onClick={() => { setConfirmError(null); setConfirmRevokeOthers(true); }}>
|
||||
{t("sessionsRevokeOthers")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Dialog open={confirmRevokeOthers} onClose={() => setConfirmRevokeOthers(false)} maxWidth="sm" fullWidth>
|
||||
<Dialog open={confirmRevokeOthers} onClose={revokingOthers ? undefined : closeConfirmation} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t("sessionsRevokeOthersConfirmTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
{confirmError ? <Alert severity="error" sx={{ mb: 2 }}>{confirmError}</Alert> : null}
|
||||
<Typography>{t("sessionsRevokeOthersConfirmBody")}</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button type="button" onClick={() => setConfirmRevokeOthers(false)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" color="warning" onClick={revokeOthers}>
|
||||
<Button type="button" onClick={closeConfirmation} disabled={revokingOthers}>{t("cancel")}</Button>
|
||||
<Button variant="contained" color="warning" onClick={revokeOthers} disabled={revokingOthers}>
|
||||
{t("sessionsRevokeOthers")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
ListItemText,
|
||||
Paper,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
@@ -25,6 +24,7 @@ import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type Status = { enabled: boolean; enabledAtUtc: string | null };
|
||||
type SetupResponse = { manualEntryKey: string; qrCodeDataUrl: string };
|
||||
@@ -55,8 +55,11 @@ function apiErrorMessage(e: any, t: (k: any) => string) {
|
||||
|
||||
export default function TwoFactorSettingsCard() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { t, language } = useI18n();
|
||||
const locale = language === "nb" ? "nb-NO" : "en";
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(true);
|
||||
const [statusError, setStatusError] = useState<string | null>(null);
|
||||
const [flow, setFlow] = useState<Flow>("closed");
|
||||
const [password, setPassword] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
@@ -70,8 +73,13 @@ export default function TwoFactorSettingsCard() {
|
||||
const [devicesError, setDevicesError] = useState<string | null>(null);
|
||||
|
||||
const loadStatus = useCallback(() => {
|
||||
api.get<Status>("/auth/2fa/status").then((r) => setStatus(r.data)).catch(() => setStatus(null));
|
||||
}, []);
|
||||
setStatusLoading(true);
|
||||
setStatusError(null);
|
||||
api.get<Status>("/auth/2fa/status")
|
||||
.then((r) => setStatus(r.data))
|
||||
.catch((e) => { setStatus(null); setStatusError(apiErrorMessage(e, t)); })
|
||||
.finally(() => setStatusLoading(false));
|
||||
}, [t]);
|
||||
|
||||
const loadDevices = useCallback(() => {
|
||||
setDevicesLoading(true);
|
||||
@@ -184,21 +192,19 @@ export default function TwoFactorSettingsCard() {
|
||||
const isRecoveryStep = flow === "enable-recovery" || flow === "regenerate-recovery";
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("twoFactorSectionTitle")}
|
||||
</Typography>
|
||||
|
||||
{status ? (
|
||||
<PageSection title={t("twoFactorSectionTitle")} surface>
|
||||
{statusLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
{statusError ? <Alert severity="error" sx={{ mb: 1.5 }} action={<Button color="inherit" onClick={loadStatus}>{t("retry")}</Button>}>{statusError}</Alert> : null}
|
||||
{status && !statusLoading ? (
|
||||
<Typography sx={{ color: "text.secondary", mb: 1.5 }}>
|
||||
{status.enabled
|
||||
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString() : "" })
|
||||
? t("twoFactorStatusEnabled", { date: status.enabledAtUtc ? new Date(status.enabledAtUtc).toLocaleDateString(locale) : "" })
|
||||
: t("twoFactorStatusDisabled")}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{!status?.enabled ? (
|
||||
{status && !statusLoading ? <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{!status.enabled ? (
|
||||
<Button variant="contained" onClick={() => setFlow("enable-password")}>
|
||||
{t("twoFactorEnableButton")}
|
||||
</Button>
|
||||
@@ -212,15 +218,16 @@ export default function TwoFactorSettingsCard() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box> : null}
|
||||
|
||||
{status?.enabled ? (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Divider sx={{ mb: 1.5 }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 0.5 }}>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mb: 0.5 }}>
|
||||
{t("twoFactorTrustedDevicesTitle")}
|
||||
</Typography>
|
||||
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }}>{devicesError}</Alert> : null}
|
||||
{devicesLoading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
{devicesError ? <Alert severity="error" role="alert" sx={{ mb: 1 }} action={<Button color="inherit" onClick={loadDevices}>{t("retry")}</Button>}>{devicesError}</Alert> : null}
|
||||
{!devicesLoading && devices.length === 0 && !devicesError ? (
|
||||
<Typography sx={{ color: "text.secondary", mb: 1 }}>{t("twoFactorTrustedDevicesEmpty")}</Typography>
|
||||
) : null}
|
||||
@@ -229,6 +236,7 @@ export default function TwoFactorSettingsCard() {
|
||||
{devices.map((d) => (
|
||||
<ListItem key={d.id} divider>
|
||||
<ListItemText
|
||||
sx={{ minWidth: 0, overflowWrap: "anywhere" }}
|
||||
primary={
|
||||
<>
|
||||
{d.deviceLabel || t("twoFactorTrustedDeviceUnknown")}
|
||||
@@ -240,8 +248,8 @@ export default function TwoFactorSettingsCard() {
|
||||
</>
|
||||
}
|
||||
secondary={t("twoFactorTrustedDeviceMeta", {
|
||||
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(),
|
||||
expires: new Date(d.expiresAtUtc).toLocaleDateString(),
|
||||
lastSeen: new Date(d.lastSeenAtUtc).toLocaleDateString(locale),
|
||||
expires: new Date(d.expiresAtUtc).toLocaleDateString(locale),
|
||||
})}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
@@ -385,6 +393,6 @@ export default function TwoFactorSettingsCard() {
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Paper>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1189,6 +1189,8 @@ export const translations = {
|
||||
profileGoogleNotLinked: "Google not linked",
|
||||
profileCvReady: "CV ready · {count} words",
|
||||
profileCvMissing: "CV missing",
|
||||
profileCvTextAvailable: "Stored CV text · {count} words",
|
||||
profileCvTextMissing: "No stored CV text",
|
||||
profileChangeImage: "Change image",
|
||||
profileRemoveImage: "Remove",
|
||||
profileImageUpdated: "Profile image updated.",
|
||||
@@ -2057,7 +2059,7 @@ export const translations = {
|
||||
googleUnlinked: "Google account unlinked.",
|
||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
||||
microsoftAccountTitle: "Microsoft account",
|
||||
microsoftSetupHint: "Set `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftSetupHint: "Microsoft sign-in is not available in this deployment.",
|
||||
microsoftLinked: "Linked",
|
||||
microsoftAvailableToLink: "Available to link",
|
||||
microsoftLinkedDate: "Linked {date}",
|
||||
@@ -3773,6 +3775,8 @@ export const translations = {
|
||||
profileGoogleNotLinked: "Google ikke koblet",
|
||||
profileCvReady: "CV klart · {count} ord",
|
||||
profileCvMissing: "CV mangler",
|
||||
profileCvTextAvailable: "Lagret CV-tekst · {count} ord",
|
||||
profileCvTextMissing: "Ingen lagret CV-tekst",
|
||||
profileChangeImage: "Bytt bilde",
|
||||
profileRemoveImage: "Fjern",
|
||||
profileImageUpdated: "Profilbildet ble oppdatert.",
|
||||
@@ -3975,7 +3979,7 @@ export const translations = {
|
||||
twoFactorInvalidCode: "Ugyldig kode. Prøv igjen.",
|
||||
twoFactorRateLimited: "For mange forsøk. Vent noen minutter og prøv igjen.",
|
||||
twoFactorGenericError: "Noe gikk galt. Prøv igjen.",
|
||||
twoFactorSectionTitle: "Topunkts autentisering",
|
||||
twoFactorSectionTitle: "Tofaktorautentisering",
|
||||
twoFactorStatusEnabled: "Aktivert siden {date}",
|
||||
twoFactorStatusDisabled: "Ikke aktivert",
|
||||
twoFactorEnableButton: "Aktiver 2FA",
|
||||
@@ -4641,7 +4645,7 @@ export const translations = {
|
||||
googleUnlinked: "Google-konto koblet fra.",
|
||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
||||
microsoftAccountTitle: "Microsoft-konto",
|
||||
microsoftSetupHint: "Sett `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftSetupHint: "Microsoft-innlogging er ikke tilgjengelig i denne installasjonen.",
|
||||
microsoftLinked: "Koblet",
|
||||
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
||||
microsoftLinkedDate: "Koblet {date}",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { getContrastRatio } from '@mui/material/styles';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
@@ -9,6 +9,7 @@ import CareerProfilePage from './views/CareerProfilePage';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
getApiErrorMessage: jest.requireActual('./api').getApiErrorMessage,
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
@@ -219,8 +220,93 @@ test('account headings separate identity from security sections', async () => {
|
||||
renderWith(ProfilePage);
|
||||
await screen.findByText('DD');
|
||||
expect(screen.getByRole('heading', { name: 'Profile', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Account', level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Change password', level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Account', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Change password', level: 2 })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Account' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Change password' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['en', 'No stored CV text', 'CV missing'],
|
||||
['nb', 'Ingen lagret CV-tekst', 'CV mangler'],
|
||||
])('account CV text status in %s does not claim the CV library is empty', async (language, textLabel, misleadingLabel) => {
|
||||
const originalRead = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation(async (url: string) => {
|
||||
const response = await originalRead(url) as { data: Record<string, unknown> };
|
||||
return url === '/auth/me' ? { ...response, data: { ...response.data, profileCvText: '' } } : response;
|
||||
});
|
||||
localStorage.setItem('uiLanguage', language);
|
||||
renderWith(ProfilePage);
|
||||
await screen.findByText('DD');
|
||||
expect(screen.getByText(textLabel)).toBeInTheDocument();
|
||||
expect(screen.queryByText(misleadingLabel)).not.toBeInTheDocument();
|
||||
expect(mockedApi.get.mock.calls.some(([url]) => String(url).startsWith('/cv/'))).toBe(false);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('[PRES-E02/E09/C01] account save carries four identity fields and keeps career, email and CV writes separate', async () => {
|
||||
renderWith(ProfilePage);
|
||||
await waitFor(() => expect(screen.getByLabelText('Display name')).toHaveValue('Demo User'));
|
||||
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Account Display' } });
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'account-user' } });
|
||||
fireEvent.change(screen.getByLabelText('First name'), { target: { value: 'Account' } });
|
||||
fireEvent.change(screen.getByLabelText('Last name'), { target: { value: 'Identity' } });
|
||||
fireEvent.change(screen.getByLabelText('New email'), { target: { value: 'unconfirmed@example.com' } });
|
||||
fireEvent.change(screen.getByLabelText(/headline/i), { target: { value: ' Local account headline ' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(1));
|
||||
expect(mockedApi.put).toHaveBeenCalledWith('/auth/profile', {
|
||||
userName: 'account-user', firstName: 'Account', lastName: 'Identity', displayName: 'Account Display',
|
||||
});
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.patch).not.toHaveBeenCalled();
|
||||
expect(mockedApi.delete).not.toHaveBeenCalled();
|
||||
expect(mockedApi.get.mock.calls.some(([url]) => String(url).startsWith('/career') || String(url).startsWith('/cv-variants'))).toBe(false);
|
||||
});
|
||||
|
||||
test('[PRES-E02/E08/E09] failed account save keeps a scoped error and the identity draft for explicit retry', async () => {
|
||||
mockedApi.put.mockRejectedValueOnce({ response: { status: 400, data: { detail: 'Username already belongs to another account.' } } });
|
||||
renderWith(ProfilePage);
|
||||
await waitFor(() => expect(screen.getByLabelText('Display name')).toHaveValue('Demo User'));
|
||||
fireEvent.change(screen.getByLabelText('Display name'), { target: { value: 'Unsaved identity' } });
|
||||
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'occupied-name' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
||||
|
||||
const account = screen.getByRole('region', { name: 'Account' });
|
||||
expect(await within(account).findByRole('alert')).toHaveTextContent('Username already belongs to another account.');
|
||||
expect(screen.getByLabelText('Display name')).toHaveValue('Unsaved identity');
|
||||
expect(screen.getByLabelText('Username')).toHaveValue('occupied-name');
|
||||
expect(within(account).getByRole('button', { name: 'Save changes' })).toBeEnabled();
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(within(account).getByRole('button', { name: 'Save changes' }));
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledTimes(2));
|
||||
expect(mockedApi.put.mock.calls[1]).toEqual(mockedApi.put.mock.calls[0]);
|
||||
await waitFor(() => expect(within(account).queryByRole('alert')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
test('[PRES-E02/E08] failed password update keeps a scoped error and secret draft until explicit retry succeeds', async () => {
|
||||
mockedApi.post.mockRejectedValueOnce({ response: { status: 400, data: { errors: { newPassword: ['The new password does not meet account requirements.'] } } } });
|
||||
renderWith(ProfilePage);
|
||||
await waitFor(() => expect(screen.getByLabelText('Current password')).toBeEnabled());
|
||||
fireEvent.change(screen.getByLabelText('Current password'), { target: { value: 'current-secret' } });
|
||||
fireEvent.change(screen.getByLabelText('New password'), { target: { value: 'draft-secret' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Update password' }));
|
||||
|
||||
const password = screen.getByRole('region', { name: 'Change password' });
|
||||
expect(await within(password).findByRole('alert')).toHaveTextContent('The new password does not meet account requirements.');
|
||||
expect(screen.getByLabelText('Current password')).toHaveValue('current-secret');
|
||||
expect(screen.getByLabelText('New password')).toHaveValue('draft-secret');
|
||||
expect(mockedApi.post).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/auth/change-password', { currentPassword: 'current-secret', newPassword: 'draft-secret' });
|
||||
|
||||
fireEvent.click(within(password).getByRole('button', { name: 'Update password' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() => expect(screen.getByLabelText('Current password')).toHaveValue(''));
|
||||
expect(screen.getByLabelText('New password')).toHaveValue('');
|
||||
expect(within(password).queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('profile page loads persisted structured cv and can re-parse it', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Avatar, Box, Button, Chip, TextField, Typography } from "@mui/material";
|
||||
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import PhotoCameraOutlinedIcon from "@mui/icons-material/PhotoCameraOutlined";
|
||||
@@ -16,6 +16,7 @@ import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { parseStructuredCvJson } from "../profileCv";
|
||||
import { getUserScopedStorageKey } from "../auth";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
@@ -66,6 +67,8 @@ export default function ProfilePage() {
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
@@ -108,7 +111,7 @@ export default function ProfilePage() {
|
||||
setLoadError(null);
|
||||
} catch (error: any) {
|
||||
setMe(null);
|
||||
setLoadError(String(error?.response?.data || error?.message || t("profileLoadFailed")));
|
||||
setLoadError(getApiErrorMessage(error, t("profileLoadFailed")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -129,10 +132,10 @@ export default function ProfilePage() {
|
||||
? t("profileGoogleLinkedWithEmail", { email: me.googleLink.email })
|
||||
: t("profileGoogleLinked")
|
||||
: t("profileGoogleNotLinked");
|
||||
const cvLabel = profileCvText.trim() ? t("profileCvReady", { count: cvWordCount }) : t("profileCvMissing");
|
||||
const cvLabel = profileCvText.trim() ? t("profileCvTextAvailable", { count: cvWordCount }) : t("profileCvTextMissing");
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)" }}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)", minWidth: 0 }}>
|
||||
<CropImageDialog
|
||||
open={cropOpen}
|
||||
file={avatarFile}
|
||||
@@ -154,7 +157,7 @@ export default function ProfilePage() {
|
||||
setAvatarFile(null);
|
||||
toast(t("profileImageUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileImageUploadFailed")), "error");
|
||||
toast(getApiErrorMessage(e, t("profileImageUploadFailed")), "error");
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
@@ -162,17 +165,20 @@ export default function ProfilePage() {
|
||||
/>
|
||||
|
||||
{loadError ? (
|
||||
<Alert severity="error" sx={{ mb: 2, borderRadius: 2.5 }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
|
||||
<Alert severity="error" sx={{ overflowWrap: "anywhere" }} action={<Button color="inherit" size="small" onClick={() => void loadProfile()}>{t("retry")}</Button>}>
|
||||
{t("profileLoadFailed")}
|
||||
<Typography variant="body2" sx={{ mt: 0.5 }}>{loadError}</Typography>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
|
||||
{loading ? <Typography role="status" variant="body2" color="text.secondary">{t("loading")}</Typography> : null}
|
||||
|
||||
<PageSection surface title={careerOnly ? "Master career profile" : t("profileTitle")}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", minWidth: 0 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap", minWidth: 0, maxWidth: "100%" }}>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 1, minWidth: 0, maxWidth: "100%" }}>
|
||||
<Avatar src={me?.avatarImageDataUrl || undefined} sx={{ width: 84, height: 84, fontWeight: 900, fontSize: 28, bgcolor: "primary.main", color: "primary.contrastText" }}>{initials}</Avatar>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center", maxWidth: "100%", "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal" } }}>
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
@@ -203,7 +209,7 @@ export default function ProfilePage() {
|
||||
setMe((prev) => (prev ? { ...prev, avatarImageDataUrl: undefined } : prev));
|
||||
toast(t("profileImageRemoved"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileImageRemoveFailed")), "error");
|
||||
toast(getApiErrorMessage(e, t("profileImageRemoveFailed")), "error");
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
@@ -214,20 +220,18 @@ export default function ProfilePage() {
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography component="h2" variant="h5" sx={{ fontWeight: 900 }}>
|
||||
{careerOnly ? "Master career profile" : t("profileTitle")}
|
||||
</Typography>
|
||||
<Box sx={{ minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Typography sx={{ color: "text.secondary" }}>{me?.userName || me?.displayName || fullName || me?.email || "-"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{headline || t("profileHeadlinePlaceholder")}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "flex-start", minWidth: 0, maxWidth: "100%", "& .MuiChip-root": { maxWidth: "100%", height: "auto", minHeight: 32 }, "& .MuiChip-label": { whiteSpace: "normal", overflowWrap: "anywhere", py: 0.5 } }}>
|
||||
<Chip label={providerLabel} color={me?.provider === "local" ? "primary" : "default"} />
|
||||
<Chip label={googleLabel} color={me?.googleLink?.linked ? "success" : "default"} variant={me?.googleLink?.linked ? "filled" : "outlined"} />
|
||||
<Chip label={cvLabel} color={profileCvText.trim() ? "success" : "warning"} variant={profileCvText.trim() ? "filled" : "outlined"} />
|
||||
</Box>
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
{!careerOnly ? <>
|
||||
<AuthStatusCard />
|
||||
@@ -235,15 +239,11 @@ export default function ProfilePage() {
|
||||
<MicrosoftAuthCard />
|
||||
</> : null}
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1" }}>
|
||||
<Typography component="h3" variant="h6">{t("profileAccountSection")}</Typography>
|
||||
{!isLocal ? (
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
{t("profileReadOnlyInfo")}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Box> : null}
|
||||
<PageSection surface title={t("profileAccountSection")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiFormHelperText-root": { overflowWrap: "anywhere" }, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal", overflowWrap: "anywhere" } }}>
|
||||
{!careerOnly && !isLocal ? <Alert severity="info" sx={{ gridColumn: "1 / -1" }}>{t("profileReadOnlyInfo")}</Alert> : null}
|
||||
|
||||
{saveError ? <Alert severity="error" sx={{ gridColumn: "1 / -1", overflowWrap: "anywhere" }}>{saveError}</Alert> : null}
|
||||
|
||||
{!careerOnly ? <>
|
||||
<TextField label={t("profileDisplayName")} value={displayName} onChange={(e) => setDisplayName(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
@@ -260,10 +260,11 @@ export default function ProfilePage() {
|
||||
/>
|
||||
</> : null}
|
||||
|
||||
{!careerOnly && isLocal ? <Box sx={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr auto auto" }, gap: 2, alignItems: "center" }}>
|
||||
{pendingEmail ? <Alert severity="info" sx={{ gridColumn: "1 / -1" }}>{t("profilePendingEmail", { email: pendingEmail })}</Alert> : null}
|
||||
{!careerOnly && isLocal ? <Box sx={{ gridColumn: "1 / -1", display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "minmax(0, 1fr) auto auto" }, gap: 2, alignItems: "center", minWidth: 0 }}>
|
||||
{pendingEmail ? <Alert severity="info" sx={{ gridColumn: "1 / -1", overflowWrap: "anywhere" }}>{t("profilePendingEmail", { email: pendingEmail })}</Alert> : null}
|
||||
<TextField label={t("profileEmailChangePassword")} type="password" value={emailChangePassword} onChange={(e) => setEmailChangePassword(e.target.value)} autoComplete="current-password" fullWidth />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
disabled={loading || !emailChangePassword || !email.trim() || email.trim().toLowerCase() === (me?.email || "").toLowerCase()}
|
||||
onClick={async () => {
|
||||
@@ -283,6 +284,7 @@ export default function ProfilePage() {
|
||||
{t("profileRequestEmailChange")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={loading || !pendingEmail || !emailChangePassword}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
@@ -306,21 +308,24 @@ export default function ProfilePage() {
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end", gap: 2, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
disabled={!isLocal || loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
// /profile saves identity only. The backend does partial updates, so omitting the
|
||||
// master-profile fields leaves them untouched (they are owned by /career).
|
||||
// /profile saves identity only. Omitting the master-profile fields leaves
|
||||
// them untouched; they are owned by /career.
|
||||
await api.put("/auth/profile", { userName, firstName, lastName, displayName });
|
||||
const userKey = me?.id || me?.email || me?.userName;
|
||||
if (userKey) window.localStorage.setItem(getUserScopedStorageKey("profileHeadline", userKey), headline.trim());
|
||||
await loadProfile();
|
||||
toast(t("profileUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("profileUpdateFailed");
|
||||
toast(String(msg), "error");
|
||||
} catch (error: any) {
|
||||
const message = getApiErrorMessage(error, t("profileUpdateFailed"));
|
||||
setSaveError(message);
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -329,12 +334,14 @@ export default function ProfilePage() {
|
||||
{t("profileSaveChanges")}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageSection>
|
||||
|
||||
{!careerOnly ? <Box sx={{ gridColumn: "1 / -1", mt: 1 }}>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Typography component="h3" variant="h6">{t("profileChangePassword")}</Typography>
|
||||
{!isLocal ? <Typography sx={{ color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
||||
</Box> : null}
|
||||
{!careerOnly ? <PageSection surface title={t("profileChangePassword")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 2, minWidth: 0, "& .MuiButton-root": { maxWidth: "100%", whiteSpace: "normal" } }}>
|
||||
{!careerOnly && !isLocal ? <Typography sx={{ gridColumn: "1 / -1", color: "text.secondary" }}>{t("profilePasswordLocalOnly")}</Typography> : null}
|
||||
|
||||
{passwordError ? <Alert severity="error" sx={{ gridColumn: "1 / -1", overflowWrap: "anywhere" }}>{passwordError}</Alert> : null}
|
||||
|
||||
{!careerOnly ? <>
|
||||
<TextField label={t("profileCurrentPassword")} type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} disabled={!isLocal} fullWidth />
|
||||
@@ -342,18 +349,21 @@ export default function ProfilePage() {
|
||||
|
||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "flex-end" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
disabled={!isLocal || loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
setPasswordError(null);
|
||||
try {
|
||||
await api.post("/auth/change-password", { currentPassword, newPassword });
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
toast(t("profilePasswordUpdated"), "success");
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("profilePasswordUpdateFailed");
|
||||
toast(String(msg), "error");
|
||||
} catch (error: any) {
|
||||
const message = getApiErrorMessage(error, t("profilePasswordUpdateFailed"));
|
||||
setPasswordError(message);
|
||||
toast(message, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -363,10 +373,11 @@ export default function ProfilePage() {
|
||||
</Button>
|
||||
</Box>
|
||||
</> : null}
|
||||
</Box>
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
|
||||
{!careerOnly && isLocal ? <TwoFactorSettingsCard /> : null}
|
||||
{!careerOnly && isLocal ? <SessionsSettingsCard /> : null}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user