feat(auth): migrate account security presentation

This commit is contained in:
cesnimda
2026-10-11 21:16:05 +02:00
parent 8ad720da5d
commit ad7b4196af
22 changed files with 35532 additions and 143 deletions
@@ -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>
);
}
+7 -3
View File
@@ -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}",
+89 -3
View File
@@ -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 () => {
+53 -42
View File
@@ -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>
);
}