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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 88 additions & 71 deletions ngui/ui/.claude/skills/ngui-ui/SKILL.md

Large diffs are not rendered by default.

14 changes: 0 additions & 14 deletions ngui/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,3 @@ OptScale, kept byte-identical so upstream syncs stay mechanical.
The Python services and the Playwright suite in `regression-tests/` (see
[`regression-tests/CLAUDE.md`](../../regression-tests/CLAUDE.md)) are out of
this file's scope.

<!-- project-summary:start — generated by ~/.claude/project-summary.mjs; edit the sources, not this block -->

## Project summary

Generated at session start from `.claude/skills/ngui-ui/SKILL.md`, `tsconfig.json`, the hooks in `.claude/settings*.json` and `~/.claude/skills` — edit those, not this block.

- **Aliases (2)** — `@theme/*` · `@main/*` — declared in `tsconfig.json`
- **Verify chain** — vitest → lint → prettier → build → translate:validate
- **Shared skills it names (6)** — `frontend-rules` · `frontend-react` · `unit-testing` · `unit-testing-vitest` · `poc-builder` · `git-workflow`
- **Own skill files (1)** — `ngui-ui/` SKILL
- **Git** — default branch `main` · commits: MPT-12345 Sentence describing the change — not Conventional Commits, and not parsed

<!-- project-summary:end -->
91 changes: 0 additions & 91 deletions ngui/ui/src/themes/finops/api/auth/signIn/README.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { postAuth } from "./request";
import { AuthToken, CompleteLoginChallenge } from "./types";

/**
* `POST /auth/v2/tokens` with a challenge — the MFA step of sign-in. `201` carries the token; a
* rejection is sorted by `getMfaVerificationFailure`. Mocked in dev by `api/mocked/`.
*/
export const completeLoginChallenge: CompleteLoginChallenge = async ({ challenge_id, method_id, code }) => {
const { data } = await postAuth<AuthToken>("/tokens", { challenge_id, method_id, code });

return data;
};
13 changes: 12 additions & 1 deletion ngui/ui/src/themes/finops/api/auth/signIn/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,16 @@ export const CREATE_TOKEN_STATUS = Object.freeze({
export const AUTH_ERROR_CODE = Object.freeze({
INVALID_CREDENTIALS: "OA0037",
INTERNAL_ERROR: "OA0061",
EMAIL_NOT_VERIFIED: "OA0073"
EMAIL_NOT_VERIFIED: "OA0073",
// Our proposal, not in the auth service yet.
INVALID_MFA_CODE: "OA0075",
MFA_ATTEMPTS_EXCEEDED: "OA0076",
LOGIN_CHALLENGE_NOT_FOUND: "OA0077"
} as const);

export const MFA_VERIFICATION_FAILURE = Object.freeze({
INVALID_CODE: "invalid_code",
CHALLENGE_EXPIRED: "challenge_expired",
CHALLENGE_LOCKED: "challenge_locked",
UNEXPECTED: "unexpected"
} as const);
3 changes: 1 addition & 2 deletions ngui/ui/src/themes/finops/api/auth/signIn/createToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { CreateToken, CreateTokenResponse } from "./types";
* `POST /auth/v2/tokens` — the password step of sign-in.
*
* `201` carries a token, `202` a login challenge. The auth service does not return the `202`
* branch yet; in dev the mocked API plugin answers this route instead. See
* [`api/mocked/README.md`](../../mocked/README.md).
* branch yet; in dev the mocked API plugin in `api/mocked/` answers this route instead.
*/
export const createToken: CreateToken = async ({ email, password }) =>
// The service's contract is not something TypeScript can verify — this is the one cast.
Expand Down
37 changes: 35 additions & 2 deletions ngui/ui/src/themes/finops/api/auth/signIn/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { AUTH_ERROR_CODE } from "./constants";
import { AuthApiError, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors";
import { AUTH_ERROR_CODE, MFA_VERIFICATION_FAILURE } from "./constants";
import { AuthApiError, getMfaVerificationFailure, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors";

const authError = (errorCode: string) => new AuthApiError({ status: 403, errorCode, reason: "nope" });

Expand Down Expand Up @@ -39,3 +39,36 @@ describe("the error predicates", () => {
expect(isEmailNotVerifiedError(thrown)).toBe(false);
});
});

describe("getMfaVerificationFailure", () => {
it("reads a wrong code as retryable, with the attempts the service says are left", () => {
const error = new AuthApiError({ status: 403, errorCode: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "nope", params: [3] });

expect(getMfaVerificationFailure(error)).toEqual({ kind: MFA_VERIFICATION_FAILURE.INVALID_CODE, attemptsLeft: 3 });
});

it("keeps a wrong code retryable when the attempts count is missing or unreadable", () => {
const error = new AuthApiError({ status: 403, errorCode: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "nope", params: ["x"] });

expect(getMfaVerificationFailure(error)).toEqual({ kind: MFA_VERIFICATION_FAILURE.INVALID_CODE, attemptsLeft: null });
});

it("reads the last wrong code as a locked challenge", () => {
const error = new AuthApiError({ status: 403, errorCode: AUTH_ERROR_CODE.MFA_ATTEMPTS_EXCEEDED, reason: "nope" });

expect(getMfaVerificationFailure(error).kind).toBe(MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED);
});

it("reads any 404 as an expired challenge, whatever code it carries", () => {
const error = new AuthApiError({ status: 404, errorCode: "OA0053", reason: "Not found" });

expect(getMfaVerificationFailure(error).kind).toBe(MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED);
});

it("does not call a network failure or an unknown 403 terminal", () => {
expect(getMfaVerificationFailure(new TypeError("Failed to fetch")).kind).toBe(MFA_VERIFICATION_FAILURE.UNEXPECTED);
expect(getMfaVerificationFailure(authError(AUTH_ERROR_CODE.INVALID_CREDENTIALS)).kind).toBe(
MFA_VERIFICATION_FAILURE.UNEXPECTED
);
});
});
50 changes: 46 additions & 4 deletions ngui/ui/src/themes/finops/api/auth/signIn/errors.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { AUTH_ERROR_CODE } from "./constants";
import { AUTH_ERROR_CODE, MFA_VERIFICATION_FAILURE } from "./constants";
import { AuthErrorParam, MfaVerificationFailure } from "./types";

/**
* Carries the optscale error envelope `{"error": {"error_code": "OA00xx", "reason": "…"}}`
* so callers can branch on the code instead of on a message.
* Carries the optscale error envelope `{"error": {"error_code": "OA00xx", "reason": "…", "params": […]}}`
* so callers can branch on the code instead of on a message. `params` fill the `%s` in `reason`.
*/
export class AuthApiError extends Error {
readonly status: number;

readonly errorCode: string;

constructor({ status, errorCode, reason }: { status: number; errorCode: string; reason: string }) {
readonly params: AuthErrorParam[];

constructor({
status,
errorCode,
reason,
params = []
}: {
status: number;
errorCode: string;
reason: string;
params?: AuthErrorParam[];
}) {
super(reason);
this.name = "AuthApiError";
this.status = status;
this.errorCode = errorCode;
this.params = params;
}
}

Expand All @@ -22,3 +36,31 @@ const hasErrorCode = (error: unknown, errorCode: string) => error instanceof Aut
export const isInvalidCredentialsError = (error: unknown): boolean => hasErrorCode(error, AUTH_ERROR_CODE.INVALID_CREDENTIALS);

export const isEmailNotVerifiedError = (error: unknown): boolean => hasErrorCode(error, AUTH_ERROR_CODE.EMAIL_NOT_VERIFIED);

const toAttemptsLeft = (value: unknown) => {
const attemptsLeft = Number(value);

return Number.isInteger(attemptsLeft) && attemptsLeft >= 0 ? attemptsLeft : null;
};

/** Sorts a rejected `completeLoginChallenge` into what the visitor can do next. */
export const getMfaVerificationFailure = (error: unknown): MfaVerificationFailure => {
if (!(error instanceof AuthApiError)) {
return { kind: MFA_VERIFICATION_FAILURE.UNEXPECTED };
}

// Unknown, expired and consumed challenges all answer 404, whatever the code.
if (error.status === 404) {
return { kind: MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED };
}

if (error.errorCode === AUTH_ERROR_CODE.MFA_ATTEMPTS_EXCEEDED) {
return { kind: MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED };
}

if (error.errorCode === AUTH_ERROR_CODE.INVALID_MFA_CODE) {
return { kind: MFA_VERIFICATION_FAILURE.INVALID_CODE, attemptsLeft: toAttemptsLeft(error.params[0]) };
}

return { kind: MFA_VERIFICATION_FAILURE.UNEXPECTED };
};
17 changes: 15 additions & 2 deletions ngui/ui/src/themes/finops/api/auth/signIn/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
export { AUTH_ERROR_CODE, CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE, LOGIN_METHOD, MFA_METHOD_TYPE } from "./constants";
export {
AUTH_ERROR_CODE,
CREATE_TOKEN_STATUS,
LOGIN_CHALLENGE_STATE,
LOGIN_METHOD,
MFA_METHOD_TYPE,
MFA_VERIFICATION_FAILURE
} from "./constants";
export { completeLoginChallenge } from "./completeLoginChallenge";
export { createToken } from "./createToken";
export { AuthApiError, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors";
export { AuthApiError, getMfaVerificationFailure, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors";
export { resolveLoginOptions } from "./loginOptions";
export type {
AuthErrorParam,
AuthToken,
CompleteLoginChallenge,
CompleteLoginChallengeParams,
CreateToken,
CreateTokenParams,
CreateTokenResponse,
Expand All @@ -14,5 +25,7 @@ export type {
LoginOptionsParams,
MfaMethod,
MfaMethodType,
MfaTerminalFailureReason,
MfaVerificationFailure,
ResolveLoginOptions
} from "./types";
4 changes: 2 additions & 2 deletions ngui/ui/src/themes/finops/api/auth/signIn/loginOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { LoginOptions, ResolveLoginOptions } from "./types";
/**
* `POST /auth/v2/login_options` — the email step of sign-in.
*
* The auth service does not expose this route yet; in dev the mocked API plugin answers it
* instead. See [`api/mocked/README.md`](../../mocked/README.md).
* The route exists on the auth service's `feature/mfa` branch but is not deployed yet; in dev the
* mocked API plugin in `api/mocked/` answers it instead.
*/
export const resolveLoginOptions: ResolveLoginOptions = async ({ email }) => {
const { data } = await postAuth<LoginOptions>("/login_options", { email });
Expand Down
12 changes: 12 additions & 0 deletions ngui/ui/src/themes/finops/api/auth/signIn/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ describe("postAuth", () => {
});
});

it("keeps the envelope's params, which carry values such as the attempts left", async () => {
respondWith(403, { error: { error_code: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "Invalid code", params: [2] } });

await expect(postAuth("/tokens", {})).rejects.toMatchObject({ params: [2] });
});

it("drops params it cannot read rather than pass on a shifted list", async () => {
respondWith(403, { error: { error_code: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "Invalid code", params: [{}, 2] } });

await expect(postAuth("/tokens", {})).rejects.toMatchObject({ params: [] });
});

it("still rejects with an AuthApiError when a gateway answers without the envelope", async () => {
respondWith(502, null);

Expand Down
10 changes: 9 additions & 1 deletion ngui/ui/src/themes/finops/api/auth/signIn/request.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
import { getApiUrl } from "api/utils";
import { AuthApiError } from "./errors";
import { AuthErrorParam } from "./types";

const AUTH_API_URL = getApiUrl("auth");

// Parsed JSON is unchecked, so `params` stays `unknown` until `isAuthErrorParams` narrows it.
type AuthErrorEnvelope = {
error?: {
error_code?: string;
reason?: string;
params?: unknown;
};
};

// All or nothing: params are positional (`params[0]` is the attempts left), so dropping one would shift the rest.
const isAuthErrorParams = (value: unknown): value is AuthErrorParam[] =>
Array.isArray(value) && value.every((param) => typeof param === "string" || typeof param === "number");

/**
* `POST` against the auth service.
*
Expand All @@ -33,7 +40,8 @@ export const postAuth = async <TData>(path: string, body: unknown): Promise<{ st
throw new AuthApiError({
status: response.status,
errorCode: error?.error_code ?? "",
reason: error?.reason ?? response.statusText
reason: error?.reason ?? response.statusText,
params: isAuthErrorParams(error?.params) ? error.params : []
});
}

Expand Down
30 changes: 29 additions & 1 deletion ngui/ui/src/themes/finops/api/auth/signIn/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { ObjectValues } from "utils/types";
import { CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE, LOGIN_METHOD, MFA_METHOD_TYPE } from "./constants";
import {
CREATE_TOKEN_STATUS,
LOGIN_CHALLENGE_STATE,
LOGIN_METHOD,
MFA_METHOD_TYPE,
MFA_VERIFICATION_FAILURE
} from "./constants";

/** A value filling a `%s` in an error's `reason` — the auth service passes strings and numbers. */
export type AuthErrorParam = string | number;

export type MfaMethodType = ObjectValues<typeof MFA_METHOD_TYPE>;

Expand Down Expand Up @@ -59,3 +68,22 @@ export type CreateTokenResponse =
| { status: typeof CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED; data: LoginChallenge };

export type CreateToken = (params: CreateTokenParams) => Promise<CreateTokenResponse>;

export type CompleteLoginChallengeParams = {
challenge_id: string;
method_id: string;
code: string;
};

export type CompleteLoginChallenge = (params: CompleteLoginChallengeParams) => Promise<AuthToken>;

export type MfaVerificationFailure =
| { kind: typeof MFA_VERIFICATION_FAILURE.INVALID_CODE; attemptsLeft: number | null }
| { kind: typeof MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED }
| { kind: typeof MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED }
| { kind: typeof MFA_VERIFICATION_FAILURE.UNEXPECTED };

/** The failures after which this challenge can never succeed — sign-in has to start over. */
export type MfaTerminalFailureReason =
| typeof MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED
| typeof MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED;
Loading
Loading