mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 18:57:09 +08:00
Merge branch 'feat/a11y' into LE-1611
This commit is contained in:
2730
.secrets.baseline
2730
.secrets.baseline
File diff suppressed because it is too large
Load Diff
@ -228,30 +228,31 @@ Work top-to-bottom. Early phases fix shared primitives first, then page surfaces
|
||||
|
||||
### 4.1 — Fix validation message announcement
|
||||
|
||||
- [ ] **Fix:** In auth flows, make validation messages screen-reader discoverable and announced when they appear.
|
||||
- [x] **Fix:** In auth flows, make validation messages screen-reader discoverable and announced when they appear.
|
||||
- `LoginPage`
|
||||
- `SignUpPage`
|
||||
- related shared form primitives
|
||||
- [ ] **Auto:** Submit empty/invalid auth form in test; assert accessible error message appears.
|
||||
- [x] **Auto:** Submit empty/invalid auth form in test; assert accessible error message appears.
|
||||
- [ ] **Manual:** Screen reader announces required/mismatch errors immediately after submit.
|
||||
|
||||
### 4.2 — Fix alert/toast semantics
|
||||
|
||||
- [ ] **Fix:** Add live-region behavior to `src/frontend/src/alerts/displayArea/index.tsx` so success/error/notice messages are announced appropriately.
|
||||
- [ ] **Auto:** Unit test dispatches alert and checks `aria-live` / urgent semantics.
|
||||
- [x] **Fix:** Add live-region behavior to `src/frontend/src/alerts/displayArea/index.tsx` so success/error/notice messages are announced appropriately.
|
||||
- [x] **Auto:** Unit test dispatches alert and checks `aria-live` / urgent semantics.
|
||||
- [ ] **Manual:** Trigger login/server error and confirm announcement without manual navigation.
|
||||
|
||||
### 4.3 — Re-verify `3.3.3` Error Suggestion
|
||||
|
||||
- [ ] **Fix:** After auth/message changes, re-test signup and auth error flows. Add concrete suggestion text where current flows only show weak mismatch feedback.
|
||||
- [ ] **Auto:** Form tests cover password mismatch and server-side auth failures.
|
||||
- [x] **Fix:** After auth/message changes, re-test signup and auth error flows. Add concrete suggestion text where current flows only show weak mismatch feedback.
|
||||
- [x] **Auto:** Form tests cover password mismatch and server-side auth failures.
|
||||
- [ ] **Manual:** Screen reader hears actionable correction suggestion, not only failure state.
|
||||
|
||||
### 4.4 — Re-verify `3.3.4` Error Prevention
|
||||
|
||||
- [ ] **Fix:** Audit destructive/data-affecting user flows after dialog fixes. Ensure confirmation and recovery flows satisfy Level 1 requirement before marking criterion done.
|
||||
- [ ] **Auto:** Add representative tests for destructive confirmation flow.
|
||||
- [ ] **Manual:** Verify delete/confirm flow with keyboard + screen reader end-to-end.
|
||||
- [x] **Fix:** Audit destructive/data-affecting user flows after dialog fixes. Criterion resolved to `FAIL` in the gap report because delete-account is a visible stub.
|
||||
- [x] **Auto:** Add representative tests for destructive confirmation flow.
|
||||
- [x] **Manual:** Document keyboard + screen-reader structural walkthrough for shared delete confirmation.
|
||||
- [ ] **Manual:** Execute live screen-reader walkthrough once the target environment is available.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
> **Scope:** Frontend code analysis against IBM `Level 1` filtered requirements
|
||||
> **Reference:** [a11y-level-1-requirements.md](/Users/viktoravelino/projects/langflow/a11y-level-1-requirements.md)
|
||||
> **Method:** Direct source code analysis + targeted re-validation of current frontend sources
|
||||
> **Latest targeted update:** 2026-06-17, LE-1518 error handling and announcements
|
||||
|
||||
---
|
||||
|
||||
@ -45,10 +46,10 @@ IBM's `Level 1` filter currently returns these `21` requirements:
|
||||
| `2.4.7` | Focus Visible | FAIL | Global and local CSS suppress visible focus indicators |
|
||||
| `3.1.1` | Language of Page | PASS | `<html lang="en">` is set |
|
||||
| `3.2.4` | Consistent Identification | FAIL | Icon-only actions remain inconsistently and often invisibly identified |
|
||||
| `3.3.1` | Error Identification | FAIL | Validation and toast error feedback are not robustly announced |
|
||||
| `3.3.1` | Error Identification | PARTIAL | Auth validation and toast announcements fixed; non-auth error surfaces still need broader audit |
|
||||
| `3.3.2` | Labels or Instructions | FAIL | Placeholder-only inputs still exist in key flows |
|
||||
| `3.3.3` | Error Suggestion | AT RISK | Weak in signup/auth flows; needs runtime confirmation for final severity |
|
||||
| `3.3.4` | Error Prevention (Legal, Financial, Data) | NEEDS TARGETED AUDIT | Destructive flows exist, but criterion not fully re-verified in this pass |
|
||||
| `3.3.3` | Error Suggestion | PASS (TARGETED AUTH) | Login/signup required, mismatch, and server-error flows now expose actionable suggestions |
|
||||
| `3.3.4` | Error Prevention (Legal, Financial, Data) | FAIL | Flow/folder/deployment confirmations exist, but delete-account is a visible stub and cannot be verified end-to-end |
|
||||
| `4.1.2` | Name, Role, Value | FAIL | Widespread issues across controls, dialogs, toggles, list cards, and canvas |
|
||||
|
||||
---
|
||||
@ -150,10 +151,15 @@ IBM's `Level 1` filter currently returns these `21` requirements:
|
||||
- Notification, toolbar, dropdown, canvas, and list-card actions rely heavily on unlabeled or inconsistently labeled icon-only patterns.
|
||||
- Same action types are not consistently exposed with the same accessible naming strategy.
|
||||
|
||||
### `3.3.1` Error Identification — FAIL
|
||||
### `3.3.1` Error Identification — PARTIAL
|
||||
|
||||
- Login form validation messages are not robustly announced in [LoginPage/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/LoginPage/index.tsx:106).
|
||||
- Alert/toast area still has no live region semantics in [alerts/displayArea/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/alerts/displayArea/index.tsx:16).
|
||||
- Login and signup required-field errors are now rendered as `role="alert"` messages and associated with their controls via `aria-describedby` in [LoginPage/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/LoginPage/index.tsx:78) and [SignUpPage/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/SignUpPage/index.tsx:86).
|
||||
- Toasts now map severity to live-region semantics in [alerts/displayArea/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/alerts/displayArea/index.tsx:18): errors use `role="alert"` / `aria-live="assertive"`; success and notice use `role="status"` / `aria-live="polite"`.
|
||||
- Automated evidence:
|
||||
- [LoginPage.a11y.test.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx:63)
|
||||
- [SignUpPage.a11y.test.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/SignUpPage/__tests__/SignUpPage.a11y.test.tsx:65)
|
||||
- [displayArea.a11y.test.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/alerts/displayArea/__tests__/displayArea.a11y.test.tsx:34)
|
||||
- Remaining risk: non-auth validation/error surfaces were not exhaustively re-audited in this LE-1518 pass.
|
||||
|
||||
### `3.3.2` Labels or Instructions — FAIL
|
||||
|
||||
@ -161,14 +167,23 @@ IBM's `Level 1` filter currently returns these `21` requirements:
|
||||
- Required markers and instructions are still not clearly exposed in all auth forms.
|
||||
- Several admin/settings/search surfaces still depend on placeholder-only or weakly associated labels.
|
||||
|
||||
### `3.3.3` Error Suggestion — AT RISK
|
||||
### `3.3.3` Error Suggestion — PASS (TARGETED AUTH)
|
||||
|
||||
- Signup password mismatch handling is weak in [SignUpPage/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/SignUpPage/index.tsx:156).
|
||||
- This criterion needs runtime confirmation before finalizing severity, but current implementation is not strong enough to call compliant.
|
||||
- Signup password mismatch now announces actionable correction text: "Passwords do not match. Re-enter both passwords so they match."
|
||||
- Server-side login errors append "Check your username and password, then try again."
|
||||
- Server-side signup errors append "Use a different username or contact an administrator if you already have an account."
|
||||
- Automated evidence:
|
||||
- [LoginPage.a11y.test.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx:87)
|
||||
- [SignUpPage.a11y.test.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/SignUpPage/__tests__/SignUpPage.a11y.test.tsx:86)
|
||||
|
||||
### `3.3.4` Error Prevention (Legal, Financial, Data) — NEEDS TARGETED AUDIT
|
||||
### `3.3.4` Error Prevention (Legal, Financial, Data) — FAIL
|
||||
|
||||
- Destructive confirmations exist, but this pass did not fully validate the requirement end-to-end against all relevant user data flows.
|
||||
- Folder/flow deletion uses [DeleteConfirmationModal](/Users/viktoravelino/projects/langflow/src/frontend/src/modals/deleteConfirmationModal/index.tsx:33), which presents a named destructive confirmation dialog, explicit permanent-delete copy, a cancel action, and a destructive delete action.
|
||||
- Shared dialog title detection now recognizes nested `DialogHeader > DialogTitle` structures in [dialog.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/components/ui/dialog.tsx:32), so delete dialogs announce their specific title instead of the fallback "Dialog".
|
||||
- Automated evidence: [DeleteConfirmationModal.a11y.test.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/modals/deleteConfirmationModal/__tests__/DeleteConfirmationModal.a11y.test.tsx:5) verifies the named dialog, permanent-delete warning, cancel path, and no accidental confirmation on cancel.
|
||||
- Deployment deletion has stronger type-to-confirm protection in [type-to-confirm-delete-dialog.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/MainPage/pages/deploymentsPage/components/type-to-confirm-delete-dialog.tsx:25).
|
||||
- Account deletion remains unimplemented: [DeleteAccountPage/index.tsx](/Users/viktoravelino/projects/langflow/src/frontend/src/pages/DeleteAccountPage/index.tsx:12) contains only placeholder comments and no real deletion/recovery path. This prevents a pass for the criterion across the targeted destructive/data-affecting flows.
|
||||
- Keyboard walkthrough, shared delete dialog: open the delete trigger, focus enters the named "Delete" dialog container, `Tab` reaches `Cancel`, `Delete`, and `Close`, `Cancel` exits without calling confirm, and activating `Delete` runs the destructive callback. Screen-reader structural announcement is supported by `role="dialog"` plus the visible `DialogTitle`; live assistive-technology execution was not performed in this source/test pass.
|
||||
|
||||
### `4.1.2` Name, Role, Value — FAIL
|
||||
|
||||
@ -191,4 +206,3 @@ IBM's `Level 1` filter currently returns these `21` requirements:
|
||||
6. Fix high-impact semantic primitives: `Input`, `CheckBoxDiv`, `AccordionTrigger`, full-screen modal container.
|
||||
7. Repair route titles so non-playground pages set meaningful `document.title`.
|
||||
8. Adjust failing text and non-text contrast tokens before page-level QA.
|
||||
|
||||
|
||||
@ -219,7 +219,15 @@ class DatabaseVariableService(VariableService, Service):
|
||||
if variable.type == CREDENTIAL_TYPE:
|
||||
from pydantic import SecretStr
|
||||
|
||||
return SecretStr(auth_utils.decrypt_api_key(variable.value))
|
||||
decrypted = auth_utils.decrypt_api_key(variable.value)
|
||||
if not decrypted:
|
||||
msg = (
|
||||
f"Could not decrypt credential variable '{name}'. The stored value cannot be "
|
||||
"decrypted with the current LANGFLOW_SECRET_KEY — it may have been encrypted "
|
||||
"with a different key."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return SecretStr(decrypted)
|
||||
# GENERIC type - return as-is
|
||||
return variable.value
|
||||
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from langflow.services.auth.utils import ensure_fernet_key
|
||||
from langflow.services.database.models.variable.model import VariableUpdate
|
||||
from langflow.services.deps import get_settings_service
|
||||
from langflow.services.variable.constants import CREDENTIAL_TYPE, GENERIC_TYPE
|
||||
@ -103,6 +106,42 @@ async def test_get_variable__typeerror(service, session: AsyncSession):
|
||||
assert "purpose is to prevent the exposure of value" in str(exc.value)
|
||||
|
||||
|
||||
async def test_get_variable__credential_decrypt_failure(service, session: AsyncSession):
|
||||
"""Store credential under SECRET_KEY=A, resolve with SECRET_KEY=B → raises naming the variable.
|
||||
|
||||
Uses ensure_fernet_key (the real SECRET_KEY→Fernet derivation path) so the test exercises
|
||||
the exact mismatch scenario described in the ticket: a key rotation or missing persisted key.
|
||||
The test auth service (lfx stub) is a passthrough, so we patch at the auth_utils boundary.
|
||||
"""
|
||||
secret_key_a = secrets.token_urlsafe(32)
|
||||
secret_key_b = secrets.token_urlsafe(32)
|
||||
fernet_a = Fernet(ensure_fernet_key(secret_key_a))
|
||||
fernet_b = Fernet(ensure_fernet_key(secret_key_b))
|
||||
|
||||
user_id = uuid4()
|
||||
name = "MY_CRED"
|
||||
|
||||
# Phase 1: store credential encrypted under SECRET_KEY = secret_key_a.
|
||||
with patch(
|
||||
"langflow.services.variable.service.auth_utils.encrypt_api_key",
|
||||
side_effect=lambda v: fernet_a.encrypt(v.encode()).decode(),
|
||||
):
|
||||
await service.create_variable(user_id, name, "secret123", type_=CREDENTIAL_TYPE, session=session)
|
||||
|
||||
# Phase 2: resolve with SECRET_KEY = secret_key_b (mismatched) — Fernet raises InvalidToken → "".
|
||||
def decrypt_with_wrong_key(ciphertext: str) -> str:
|
||||
try:
|
||||
return fernet_b.decrypt(ciphertext.encode()).decode()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
with (
|
||||
patch("langflow.services.variable.service.auth_utils.decrypt_api_key", side_effect=decrypt_with_wrong_key),
|
||||
pytest.raises(ValueError, match=r"MY_CRED.*SECRET_KEY"),
|
||||
):
|
||||
await service.get_variable(user_id, name, "", session=session)
|
||||
|
||||
|
||||
async def test_list_variables(service, session: AsyncSession):
|
||||
user_id = uuid4()
|
||||
names = ["name1", "name2", "name3"]
|
||||
|
||||
@ -76,7 +76,7 @@ export default function OutputComponent({
|
||||
unstyled
|
||||
role="combobox"
|
||||
ref={refButton}
|
||||
className="no-focus-visible group flex items-center gap-2"
|
||||
className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 group flex items-center gap-2"
|
||||
data-testid={`dropdown-output-${outputName?.toLowerCase()}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 truncate rounded-md px-2 py-1 text-sm font-medium group-hover:bg-primary/10">
|
||||
|
||||
@ -31,14 +31,41 @@ describe("AlertDisplayArea accessibility", () => {
|
||||
expect(screen.getByText("Flow saved")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Known gap (a11y-action-plan 4.2): the alert display area has no
|
||||
// aria-live region, so success/error/notice messages are never announced
|
||||
// to screen readers. Fails until the fix lands.
|
||||
it("should_announce_alerts_via_live_region", () => {
|
||||
it("keeps_live_regions_mounted_before_alerts_are_inserted", () => {
|
||||
const { container } = render(<AlertDisplayArea />);
|
||||
|
||||
const wrapper = container.firstElementChild;
|
||||
expect(wrapper).not.toBeNull();
|
||||
expect(wrapper).toHaveAttribute("aria-live");
|
||||
expect(
|
||||
container.querySelector('[aria-live="assertive"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite");
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces_success_and_notice_alerts_politely", () => {
|
||||
render(<AlertDisplayArea />);
|
||||
|
||||
act(() => {
|
||||
useAlertStore.getState().setSuccessData({ title: "Flow saved" });
|
||||
});
|
||||
|
||||
const status = screen.getByRole("status");
|
||||
expect(status).toHaveAttribute("aria-live", "polite");
|
||||
expect(status).toHaveAttribute("aria-atomic", "true");
|
||||
expect(status).toHaveTextContent("Flow saved");
|
||||
});
|
||||
|
||||
it("announces_error_alerts_assertively", () => {
|
||||
const { container } = render(<AlertDisplayArea />);
|
||||
|
||||
act(() => {
|
||||
useAlertStore.getState().setErrorData({ title: "Build failed" });
|
||||
});
|
||||
|
||||
const assertiveRegion = container.querySelector('[aria-live="assertive"]');
|
||||
expect(assertiveRegion).toHaveAttribute("aria-atomic", "true");
|
||||
expect(assertiveRegion).toHaveTextContent("Build failed");
|
||||
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert).toHaveTextContent("Build failed");
|
||||
});
|
||||
});
|
||||
|
||||
@ -13,25 +13,39 @@ export default function AlertDisplayArea() {
|
||||
const removeAlert = (id: string) => {
|
||||
removeFromTempNotificationList(id);
|
||||
};
|
||||
const errorAlerts = tempNotificationList.filter(
|
||||
(alert) => alert.type === "error",
|
||||
);
|
||||
const politeAlerts = tempNotificationList.filter(
|
||||
(alert) => alert.type !== "error",
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
className="flex flex-col-reverse"
|
||||
style={{ zIndex: 999 }}
|
||||
>
|
||||
{tempNotificationList.map((alert) => (
|
||||
<div key={alert.id}>
|
||||
{alert.type === "error" ? (
|
||||
<div style={{ zIndex: 999 }}>
|
||||
<div
|
||||
aria-atomic="true"
|
||||
aria-live="assertive"
|
||||
className="flex flex-col-reverse"
|
||||
>
|
||||
{errorAlerts.map((alert) => (
|
||||
<div key={alert.id} role="alert">
|
||||
<ErrorAlert
|
||||
key={alert.id}
|
||||
title={alert.title}
|
||||
list={alert.list}
|
||||
id={alert.id}
|
||||
removeAlert={removeAlert}
|
||||
/>
|
||||
) : alert.type === "notice" ? (
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
aria-atomic="true"
|
||||
aria-live="polite"
|
||||
className="flex flex-col-reverse"
|
||||
role="status"
|
||||
>
|
||||
{politeAlerts.map((alert) =>
|
||||
alert.type === "notice" ? (
|
||||
<NoticeAlert
|
||||
key={alert.id}
|
||||
title={alert.title}
|
||||
@ -48,9 +62,9 @@ export default function AlertDisplayArea() {
|
||||
removeAlert={removeAlert}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,7 +3,14 @@ import type { ReactNode } from "react";
|
||||
export function GradientWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<svg width="0" height="0" className="absolute">
|
||||
<svg
|
||||
width="0"
|
||||
height="0"
|
||||
className="absolute"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
role="presentation"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="x-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="-35.61%" stopColor="#e6b1e1" />
|
||||
|
||||
@ -380,7 +380,7 @@ export default function Dropdown({
|
||||
editNode
|
||||
? "dropdown-component-outline input-edit-node"
|
||||
: "dropdown-component-false-outline py-2",
|
||||
"no-focus-visible w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
|
||||
"focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@ -151,7 +151,7 @@ export function DBProviderInput({
|
||||
data-testid={id}
|
||||
className={cn(
|
||||
"dropdown-component-false-outline py-2",
|
||||
"no-focus-visible w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
|
||||
"focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@ -30,6 +30,7 @@ interface FormInputBranchProps {
|
||||
blurOnEnter: boolean;
|
||||
name?: string;
|
||||
id: string;
|
||||
inputProps?: React.InputHTMLAttributes<HTMLInputElement>;
|
||||
}
|
||||
|
||||
function FormInputBranch({
|
||||
@ -49,6 +50,7 @@ function FormInputBranch({
|
||||
blurOnEnter,
|
||||
name,
|
||||
id,
|
||||
inputProps,
|
||||
}: FormInputBranchProps) {
|
||||
const [cursor, setCursor] = useState<number | null>(null);
|
||||
|
||||
@ -88,6 +90,7 @@ function FormInputBranch({
|
||||
return (
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
{...inputProps}
|
||||
name={name}
|
||||
id={"form-" + id}
|
||||
ref={refInput}
|
||||
@ -145,6 +148,7 @@ export default function InputComponent({
|
||||
objectOptions,
|
||||
isObjectOption = false,
|
||||
name,
|
||||
inputProps,
|
||||
onChangeFolderName,
|
||||
nodeStyle,
|
||||
isToolMode,
|
||||
@ -190,6 +194,7 @@ export default function InputComponent({
|
||||
blurOnEnter={blurOnEnter}
|
||||
name={name}
|
||||
id={id}
|
||||
inputProps={inputProps}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -82,7 +82,7 @@ const ModelTrigger = ({
|
||||
data-testid={id}
|
||||
className={cn(
|
||||
"dropdown-component-false-outline py-2",
|
||||
"no-focus-visible w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
|
||||
"focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
|
||||
@ -23,7 +23,7 @@ export function ChatHeaderActions({
|
||||
}
|
||||
|
||||
const actionButtonClasses =
|
||||
"h-8 w-8 p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors overflow-hidden no-focus-visible";
|
||||
"h-8 w-8 p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors overflow-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2";
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 w-20 justify-end">
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../dialog";
|
||||
|
||||
@ -75,4 +76,48 @@ describe("DialogContent", () => {
|
||||
screen.queryByRole("button", { name: /close/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should_detect_dialog_title_and_description_inside_dialog_header", () => {
|
||||
renderWithProviders(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nested title</DialogTitle>
|
||||
<DialogDescription>Nested description</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole("dialog", { name: "Nested title" });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
expect(dialog).toHaveAccessibleDescription("Nested description");
|
||||
expect(screen.queryByText("Dialog")).not.toBeInTheDocument();
|
||||
expect(document.querySelectorAll("p")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should_stop_scanning_for_dialog_title_after_safe_depth", () => {
|
||||
renderWithProviders(
|
||||
<Dialog open>
|
||||
<DialogContent>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<DialogTitle>Too deep</DialogTitle>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogDescription>Test description</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Dialog")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@ -34,6 +34,26 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const MAX_DIALOG_CHILD_SCAN_DEPTH = 4;
|
||||
|
||||
const hasChildOfType = (
|
||||
children: React.ReactNode,
|
||||
targetType: React.ElementType,
|
||||
depth = 0,
|
||||
): boolean =>
|
||||
React.Children.toArray(children).some((child) => {
|
||||
if (!React.isValidElement<{ children?: React.ReactNode }>(child)) {
|
||||
return false;
|
||||
}
|
||||
if (child.type === targetType) {
|
||||
return true;
|
||||
}
|
||||
if (depth >= MAX_DIALOG_CHILD_SCAN_DEPTH) {
|
||||
return false;
|
||||
}
|
||||
return hasChildOfType(child.props.children, targetType, depth + 1);
|
||||
});
|
||||
|
||||
// Create a VisuallyHidden component for accessibility
|
||||
const VisuallyHidden = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
@ -73,13 +93,8 @@ const DialogContent = React.forwardRef<
|
||||
ref,
|
||||
) => {
|
||||
// Check if DialogTitle is included in children
|
||||
const hasDialogTitle = React.Children.toArray(children).some(
|
||||
(child) => React.isValidElement(child) && child.type === DialogTitle,
|
||||
);
|
||||
const hasDialogDescription = React.Children.toArray(children).some(
|
||||
(child) =>
|
||||
React.isValidElement(child) && child.type === DialogDescription,
|
||||
);
|
||||
const hasDialogTitle = hasChildOfType(children, DialogTitle);
|
||||
const hasDialogDescription = hasChildOfType(children, DialogDescription);
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
|
||||
@ -625,6 +625,7 @@
|
||||
"errors.noResponseBody": "Kein Antworttext",
|
||||
"errors.parameterNotFound": "Parameter in der Vorlage nicht gefunden",
|
||||
"errors.passwordMismatch": "Kennwörter stimmen nicht überein",
|
||||
"errors.passwordMismatchSuggestion": "Geben Sie beide Kennwörter erneut ein, damit sie übereinstimmen.",
|
||||
"errors.profilePictures": "Fehler beim Abrufen der Profilbilder",
|
||||
"errors.prompt": "Mit dieser Eingabeaufforderung stimmt etwas nicht. Bitte überprüfe sie",
|
||||
"errors.receivedMalformedEvent": "Vom Server wurde ein fehlerhaftes Ereignis empfangen",
|
||||
@ -637,7 +638,9 @@
|
||||
"errors.sendMessage": "Beim Senden der Nachricht ist ein Fehler aufgetreten",
|
||||
"errors.sessionDeletedLocalStorage": "Sitzung aus dem lokalen Speicher gelöscht",
|
||||
"errors.signin": "Fehler beim Anmelden",
|
||||
"errors.signinSuggestion": "Überprüfen Sie Ihren Benutzernamen und Ihr Kennwort und versuchen Sie es erneut.",
|
||||
"errors.signup": "Fehler bei der Anmeldung",
|
||||
"errors.signupSuggestion": "Verwenden Sie einen anderen Benutzernamen oder wenden Sie sich an einen Administrator, wenn Sie bereits ein Konto haben.",
|
||||
"errors.templateNotFound": "Vorlage in der Komponente nicht gefunden",
|
||||
"errors.tooManyFiles": "Es wurden zu viele Dateien erkannt ( {{count}} ). Dazu gehören wahrscheinlich auch große bzw. versteckte Verzeichnisse. Bitte wählen Sie einen kleineren Ordner aus oder schließen Sie Ordner wie „node_modules“ aus.",
|
||||
"errors.transformUndefined": "Die Umwandlung ergab einen undefinierten Wert",
|
||||
|
||||
@ -27,14 +27,17 @@
|
||||
"errors.editUser": "Error on edit user",
|
||||
"errors.addUser": "Error when adding new user",
|
||||
"errors.signin": "Error signing in",
|
||||
"errors.signinSuggestion": "Check your username and password, then try again.",
|
||||
"errors.deleteKey": "Error on delete key",
|
||||
"errors.deleteKeys": "Error on delete keys",
|
||||
"errors.invalidSelection": "Invalid selection",
|
||||
"errors.changePassword": "Error changing password",
|
||||
"errors.passwordMismatch": "Passwords do not match",
|
||||
"errors.passwordMismatchSuggestion": "Re-enter both passwords so they match.",
|
||||
"errors.saveChanges": "Error saving changes",
|
||||
"errors.profilePictures": "Error retrieving profile pictures",
|
||||
"errors.signup": "Error signing up",
|
||||
"errors.signupSuggestion": "Use a different username or contact an administrator if you already have an account.",
|
||||
"errors.apiKey": "API Key Error",
|
||||
"errors.noApiKey": "You don't have an API Key. Please add one to use the Langflow Store.",
|
||||
"errors.invalidApiKey": "Your API Key is not valid. Please add a valid API Key to use the Langflow Store.",
|
||||
|
||||
@ -625,6 +625,7 @@
|
||||
"errors.noResponseBody": "No hay cuerpo de respuesta",
|
||||
"errors.parameterNotFound": "Parámetro no encontrado en la plantilla",
|
||||
"errors.passwordMismatch": "Las contraseñas no coinciden",
|
||||
"errors.passwordMismatchSuggestion": "Vuelve a introducir ambas contraseñas para que coincidan.",
|
||||
"errors.profilePictures": "Error al recuperar las fotos de perfil",
|
||||
"errors.prompt": "Hay un error en esta indicación; por favor, revísala",
|
||||
"errors.receivedMalformedEvent": "Se ha recibido un evento con formato incorrecto del servidor",
|
||||
@ -637,7 +638,9 @@
|
||||
"errors.sendMessage": "Se ha producido un error al enviar el mensaje",
|
||||
"errors.sessionDeletedLocalStorage": "Sesión eliminada del almacenamiento local",
|
||||
"errors.signin": "Error al iniciar sesión",
|
||||
"errors.signinSuggestion": "Comprueba tu nombre de usuario y contraseña e inténtalo de nuevo.",
|
||||
"errors.signup": "Error al registrarse",
|
||||
"errors.signupSuggestion": "Usa otro nombre de usuario o ponte en contacto con un administrador si ya tienes una cuenta.",
|
||||
"errors.templateNotFound": "No se ha encontrado la plantilla en el componente",
|
||||
"errors.tooManyFiles": "Se han detectado demasiados archivos ( {{count}} ). Es probable que esto incluya directorios grandes u ocultos. Selecciona una carpeta más pequeña o excluye carpetas como «node_modules».",
|
||||
"errors.transformUndefined": "La transformación ha dado como resultado un valor indefinido",
|
||||
|
||||
@ -625,6 +625,7 @@
|
||||
"errors.noResponseBody": "Pas de corps de réponse",
|
||||
"errors.parameterNotFound": "Paramètre introuvable dans le modèle",
|
||||
"errors.passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"errors.passwordMismatchSuggestion": "Saisissez à nouveau les deux mots de passe afin qu'ils correspondent.",
|
||||
"errors.profilePictures": "Erreur lors de la récupération des photos de profil",
|
||||
"errors.prompt": "Il y a un problème avec cette consigne, merci de la vérifier",
|
||||
"errors.receivedMalformedEvent": "Événement mal formé reçu du serveur",
|
||||
@ -637,7 +638,9 @@
|
||||
"errors.sendMessage": "Une erreur s'est produite lors de l'envoi du message",
|
||||
"errors.sessionDeletedLocalStorage": "Session supprimée de la mémoire locale",
|
||||
"errors.signin": "Erreur lors de la connexion",
|
||||
"errors.signinSuggestion": "Vérifiez votre nom d'utilisateur et votre mot de passe, puis réessayez.",
|
||||
"errors.signup": "Erreur lors de l'inscription",
|
||||
"errors.signupSuggestion": "Utilisez un autre nom d'utilisateur ou contactez un administrateur si vous avez déjà un compte.",
|
||||
"errors.templateNotFound": "Modèle introuvable dans le composant",
|
||||
"errors.tooManyFiles": "Trop de fichiers détectés ( {{count}} ). Cela inclut probablement les répertoires volumineux ou cachés. Veuillez sélectionner un dossier plus petit ou exclure des dossiers tels que node_modules.",
|
||||
"errors.transformUndefined": "La transformation a donné lieu à une valeur indéfinie",
|
||||
|
||||
@ -625,6 +625,7 @@
|
||||
"errors.noResponseBody": "レスポンス本文なし",
|
||||
"errors.parameterNotFound": "テンプレートにそのパラメータが見つかりません",
|
||||
"errors.passwordMismatch": "パスワードが一致しません",
|
||||
"errors.passwordMismatchSuggestion": "両方のパスワードを一致するように再入力してください。",
|
||||
"errors.profilePictures": "プロフィール画像の取得に失敗しました",
|
||||
"errors.prompt": "このプロンプトに問題があります。確認してください",
|
||||
"errors.receivedMalformedEvent": "サーバーから形式が不正なイベントを受信しました",
|
||||
@ -637,7 +638,9 @@
|
||||
"errors.sendMessage": "メッセージの送信中にエラーが発生しました",
|
||||
"errors.sessionDeletedLocalStorage": "ローカルストレージからセッションが削除されました",
|
||||
"errors.signin": "ログイン中にエラーが発生しました",
|
||||
"errors.signinSuggestion": "ユーザー名とパスワードを確認して、もう一度お試しください。",
|
||||
"errors.signup": "登録中にエラーが発生しました",
|
||||
"errors.signupSuggestion": "別のユーザー名を使用するか、すでにアカウントをお持ちの場合は管理者にお問い合わせください。",
|
||||
"errors.templateNotFound": "コンポーネント内にテンプレートが見つかりません",
|
||||
"errors.tooManyFiles": "ファイルが多すぎます( {{count}} )。 これには、大規模なディレクトリや隠しディレクトリも含まれる可能性が高い。 より小さなフォルダを選択するか、node_modules などのフォルダを除外してください。",
|
||||
"errors.transformUndefined": "変換の結果、未定義の値が得られました",
|
||||
|
||||
@ -625,6 +625,7 @@
|
||||
"errors.noResponseBody": "Sem corpo da resposta",
|
||||
"errors.parameterNotFound": "Parâmetro não encontrado no modelo",
|
||||
"errors.passwordMismatch": "As senhas não correspondem",
|
||||
"errors.passwordMismatchSuggestion": "Digite novamente as duas senhas para que elas correspondam.",
|
||||
"errors.profilePictures": "Erro ao recuperar fotos de perfil",
|
||||
"errors.prompt": "Há algo de errado com esta solicitação; por favor, verifique-a",
|
||||
"errors.receivedMalformedEvent": "Recebido evento com formato inválido do servidor",
|
||||
@ -637,7 +638,9 @@
|
||||
"errors.sendMessage": "Ocorreu um erro ao enviar a mensagem",
|
||||
"errors.sessionDeletedLocalStorage": "Sessão excluída do armazenamento local",
|
||||
"errors.signin": "Erro ao fazer login",
|
||||
"errors.signinSuggestion": "Verifique seu nome de usuário e senha e tente novamente.",
|
||||
"errors.signup": "Erro ao se inscrever",
|
||||
"errors.signupSuggestion": "Use um nome de usuário diferente ou entre em contato com um administrador se você já tiver uma conta.",
|
||||
"errors.templateNotFound": "Modelo não encontrado no componente",
|
||||
"errors.tooManyFiles": "Foram detectados muitos arquivos ( {{count}} ). Isso provavelmente inclui diretórios grandes ou ocultos. Selecione uma pasta menor ou exclua pastas como node_modules.",
|
||||
"errors.transformUndefined": "A transformação resultou em um valor indefinido",
|
||||
|
||||
@ -625,6 +625,7 @@
|
||||
"errors.noResponseBody": "没有响应正文",
|
||||
"errors.parameterNotFound": "模板中未找到该参数",
|
||||
"errors.passwordMismatch": "密码不匹配",
|
||||
"errors.passwordMismatchSuggestion": "请重新输入两次密码以确保一致。",
|
||||
"errors.profilePictures": "无法获取个人头像",
|
||||
"errors.prompt": "此提示存在问题,请检查一下",
|
||||
"errors.receivedMalformedEvent": "从服务器收到格式错误的事件",
|
||||
@ -637,7 +638,9 @@
|
||||
"errors.sendMessage": "发送消息时发生错误",
|
||||
"errors.sessionDeletedLocalStorage": "已从本地存储中删除会话",
|
||||
"errors.signin": "登录失败",
|
||||
"errors.signinSuggestion": "请检查您的用户名和密码,然后重试。",
|
||||
"errors.signup": "注册失败",
|
||||
"errors.signupSuggestion": "请使用其他用户名;如果您已有账户,请联系管理员。",
|
||||
"errors.templateNotFound": "在组件中未找到该模板",
|
||||
"errors.tooManyFiles": "检测到文件过多( {{count}} )。 这可能包括大型或隐藏的目录。 请选择一个较小的文件夹,或排除如 node_modules 之类的文件夹。",
|
||||
"errors.transformUndefined": "转换后得到未定义的值",
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import DeleteConfirmationModal from "../index";
|
||||
|
||||
describe("DeleteConfirmationModal accessibility", () => {
|
||||
it("exposes_destructive_confirmation_copy_and_cancel_path", () => {
|
||||
const onConfirm = jest.fn();
|
||||
|
||||
render(
|
||||
<DeleteConfirmationModal
|
||||
open={true}
|
||||
setOpen={jest.fn()}
|
||||
onConfirm={onConfirm}
|
||||
description="folder"
|
||||
note="and all associated flows and components"
|
||||
asChild
|
||||
>
|
||||
<button type="button">Open delete dialog</button>
|
||||
</DeleteConfirmationModal>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Delete" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((content) =>
|
||||
content.includes(
|
||||
"This will permanently delete the folder and all associated flows and components.",
|
||||
),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText((content) => content.includes("This can't be undone.")),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId("btn_cancel_delete_confirmation_modal"),
|
||||
).toHaveTextContent("Cancel");
|
||||
expect(
|
||||
screen.getByTestId("btn_delete_delete_confirmation_modal"),
|
||||
).toHaveTextContent("Delete");
|
||||
|
||||
fireEvent.click(screen.getByTestId("btn_cancel_delete_confirmation_modal"));
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps_trigger_reachable_by_keyboard_tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<DeleteConfirmationModal
|
||||
onConfirm={jest.fn()}
|
||||
description="folder"
|
||||
asChild
|
||||
>
|
||||
<button type="button">Open delete dialog</button>
|
||||
</DeleteConfirmationModal>,
|
||||
);
|
||||
|
||||
const trigger = screen.getByRole("button", {
|
||||
name: "Open delete dialog",
|
||||
});
|
||||
|
||||
expect(trigger).not.toHaveAttribute("tabindex", "-1");
|
||||
|
||||
await user.tab();
|
||||
|
||||
expect(trigger).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@ -32,7 +32,7 @@ export default function DeleteConfirmationModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild={!children ? true : asChild} tabIndex={-1}>
|
||||
<DialogTrigger asChild={!children ? true : asChild}>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
|
||||
@ -0,0 +1,160 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { axe } from "@/utils/a11y-test";
|
||||
import LoginPage from "../index";
|
||||
|
||||
const mockLoginMutate = jest.fn();
|
||||
|
||||
jest.mock("@/assets/LangflowLogo.svg?react", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
|
||||
}));
|
||||
|
||||
jest.mock("@radix-ui/react-form", () => {
|
||||
const React = require("react");
|
||||
return {
|
||||
__esModule: true,
|
||||
Root: ({ children, ...props }) =>
|
||||
React.createElement("form", props, children),
|
||||
Field: ({ children }) => React.createElement("div", null, children),
|
||||
Label: ({ children, ...props }) =>
|
||||
React.createElement("label", props, children),
|
||||
Control: ({ children }) => children,
|
||||
Message: ({ children, ...props }) =>
|
||||
React.createElement("p", props, children),
|
||||
Submit: ({ children }) => children,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("@/controllers/API/queries/auth", () => ({
|
||||
useLoginUser: () => ({ mutate: mockLoginMutate }),
|
||||
}));
|
||||
|
||||
jest.mock("@/customization/components/custom-link", () => ({
|
||||
CustomLink: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/hooks/use-sanitize-redirect-url", () => ({
|
||||
useSanitizeRedirectUrl: jest.fn(),
|
||||
}));
|
||||
|
||||
function renderLoginPage() {
|
||||
const queryClient = new QueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LoginPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginPage accessibility", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAlertStore.setState({
|
||||
notificationList: [],
|
||||
tempNotificationList: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("announces_required_field_errors_on_empty_submit", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLoginPage();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Please enter your username"),
|
||||
).toHaveAttribute("role", "alert");
|
||||
expect(screen.getByText("Please enter your password")).toHaveAttribute(
|
||||
"role",
|
||||
"alert",
|
||||
);
|
||||
expect(screen.getByPlaceholderText("Username")).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"login-username-error",
|
||||
);
|
||||
expect(screen.getByPlaceholderText("Password")).toHaveAttribute(
|
||||
"aria-describedby",
|
||||
"login-password-error",
|
||||
);
|
||||
expect(mockLoginMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should_have_no_axe_violations", async () => {
|
||||
const { container } = renderLoginPage();
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("uses_valid_external_labels_for_username_and_password", () => {
|
||||
renderLoginPage();
|
||||
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /username/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText(/^Password/i, { selector: "input" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("adds_actionable_suggestion_to_server_login_errors", () => {
|
||||
mockLoginMutate.mockImplementation((_user, options) => {
|
||||
options.onError({
|
||||
response: { data: { detail: "Incorrect username or password." } },
|
||||
});
|
||||
});
|
||||
const { container } = renderLoginPage();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Username"), {
|
||||
target: { value: "alice" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Password"), {
|
||||
target: { value: "wrong-password" },
|
||||
});
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
|
||||
expect(useAlertStore.getState().tempNotificationList[0]).toMatchObject({
|
||||
title: "Error signing in",
|
||||
type: "error",
|
||||
list: [
|
||||
"Incorrect username or password. Check your username and password, then try again.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves_fastapi_validation_error_details_for_login_errors", () => {
|
||||
mockLoginMutate.mockImplementation((_user, options) => {
|
||||
options.onError({
|
||||
response: {
|
||||
data: {
|
||||
detail: [
|
||||
{ msg: "Username must be a string" },
|
||||
{ msg: "Password is required" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const { container } = renderLoginPage();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Username"), {
|
||||
target: { value: "alice" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Password"), {
|
||||
target: { value: "wrong-password" },
|
||||
});
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
|
||||
expect(useAlertStore.getState().tempNotificationList[0]).toMatchObject({
|
||||
title: "Error signing in",
|
||||
type: "error",
|
||||
list: [
|
||||
"Username must be a string; Password is required. Check your username and password, then try again.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -4,9 +4,14 @@ import { useContext, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LangflowLogo from "@/assets/LangflowLogo.svg?react";
|
||||
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||
import { extractApiErrorMessage } from "@/controllers/API/helpers/extract-api-error-message";
|
||||
import { useLoginUser } from "@/controllers/API/queries/auth";
|
||||
import { CustomLink } from "@/customization/components/custom-link";
|
||||
import { useSanitizeRedirectUrl } from "@/hooks/use-sanitize-redirect-url";
|
||||
import {
|
||||
appendErrorSuggestion,
|
||||
getRequiredFieldError,
|
||||
} from "@/utils/authErrorMessages";
|
||||
import InputComponent from "../../components/core/parameterRenderComponent/components/inputComponent";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
@ -22,6 +27,7 @@ import type {
|
||||
export default function LoginPage(): JSX.Element {
|
||||
const [inputState, setInputState] =
|
||||
useState<loginInputStateType>(CONTROL_LOGIN_STATE);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
|
||||
const { password, username } = inputState;
|
||||
|
||||
@ -55,16 +61,40 @@ export default function LoginPage(): JSX.Element {
|
||||
onError: (error) => {
|
||||
setErrorData({
|
||||
title: t("errors.signin"),
|
||||
list: [error["response"]["data"]["detail"]],
|
||||
list: [
|
||||
appendErrorSuggestion(
|
||||
extractApiErrorMessage(
|
||||
error as Parameters<typeof extractApiErrorMessage>[0],
|
||||
t("errors.signin"),
|
||||
),
|
||||
t("errors.signinSuggestion", {
|
||||
defaultValue:
|
||||
"Check your username and password, then try again.",
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const usernameError = getRequiredFieldError(
|
||||
submitAttempted,
|
||||
username,
|
||||
t("auth.usernameRequired"),
|
||||
);
|
||||
const passwordError = getRequiredFieldError(
|
||||
submitAttempted,
|
||||
password,
|
||||
t("auth.passwordRequired"),
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Root
|
||||
onInvalidCapture={() => setSubmitAttempted(true)}
|
||||
onSubmit={(event) => {
|
||||
if (password === "") {
|
||||
setSubmitAttempted(true);
|
||||
if (username.trim() === "" || password.trim() === "") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
@ -85,35 +115,56 @@ export default function LoginPage(): JSX.Element {
|
||||
</span>
|
||||
<div className="mb-3 w-full">
|
||||
<Form.Field name="username">
|
||||
<Form.Label className="data-[invalid]:label-invalid flex items-center gap-1 overflow-hidden">
|
||||
<label
|
||||
htmlFor="login-username"
|
||||
className={`flex items-center gap-1 overflow-hidden ${
|
||||
usernameError ? "label-invalid" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{t("auth.usernameLabel")}</span>
|
||||
<span className="shrink-0 font-medium text-destructive">*</span>
|
||||
</Form.Label>
|
||||
</label>
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
type="username"
|
||||
onChange={({ target: { value } }) => {
|
||||
handleInput({ target: { name: "username", value } });
|
||||
}}
|
||||
value={username}
|
||||
className="w-full"
|
||||
required
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
onChange={({ target: { value } }) => {
|
||||
handleInput({ target: { name: "username", value } });
|
||||
}}
|
||||
value={username}
|
||||
className="w-full"
|
||||
required
|
||||
aria-describedby={
|
||||
usernameError ? "login-username-error" : undefined
|
||||
}
|
||||
aria-invalid={Boolean(usernameError)}
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
/>
|
||||
|
||||
<Form.Message match="valueMissing" className="field-invalid">
|
||||
{t("auth.usernameRequired")}
|
||||
</Form.Message>
|
||||
{usernameError && (
|
||||
<p
|
||||
id="login-username-error"
|
||||
role="alert"
|
||||
className="field-invalid"
|
||||
>
|
||||
{usernameError}
|
||||
</p>
|
||||
)}
|
||||
</Form.Field>
|
||||
</div>
|
||||
<div className="mb-3 w-full">
|
||||
<Form.Field name="password">
|
||||
<Form.Label className="data-[invalid]:label-invalid flex items-center gap-1 overflow-hidden">
|
||||
<label
|
||||
htmlFor="form-login-password"
|
||||
className={`flex items-center gap-1 overflow-hidden ${
|
||||
passwordError ? "label-invalid" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{t("auth.passwordLabel")}</span>
|
||||
<span className="shrink-0 font-medium text-destructive">*</span>
|
||||
</Form.Label>
|
||||
</label>
|
||||
|
||||
<InputComponent
|
||||
onChange={(value) => {
|
||||
@ -123,13 +174,26 @@ export default function LoginPage(): JSX.Element {
|
||||
isForm
|
||||
password={true}
|
||||
required
|
||||
id="login-password"
|
||||
inputProps={{
|
||||
"aria-describedby": passwordError
|
||||
? "login-password-error"
|
||||
: undefined,
|
||||
"aria-invalid": Boolean(passwordError) || undefined,
|
||||
}}
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Form.Message className="field-invalid" match="valueMissing">
|
||||
{t("auth.passwordRequired")}
|
||||
</Form.Message>
|
||||
{passwordError && (
|
||||
<p
|
||||
id="login-password-error"
|
||||
role="alert"
|
||||
className="field-invalid"
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</Form.Field>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
|
||||
@ -0,0 +1,192 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import useAlertStore from "@/stores/alertStore";
|
||||
import { axe } from "@/utils/a11y-test";
|
||||
import SignUp from "../index";
|
||||
|
||||
const mockAddUserMutate = jest.fn();
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
jest.mock("@/assets/LangflowLogo.svg?react", () => ({
|
||||
__esModule: true,
|
||||
default: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
|
||||
}));
|
||||
|
||||
jest.mock("@radix-ui/react-form", () => {
|
||||
const React = require("react");
|
||||
return {
|
||||
__esModule: true,
|
||||
Root: ({ children, ...props }) =>
|
||||
React.createElement("form", props, children),
|
||||
Field: ({ children }) => React.createElement("div", null, children),
|
||||
Label: ({ children, ...props }) =>
|
||||
React.createElement("label", props, children),
|
||||
Control: ({ children }) => children,
|
||||
Message: ({ children, ...props }) =>
|
||||
React.createElement("p", props, children),
|
||||
Submit: ({ children }) => children,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("@/controllers/API/queries/auth", () => ({
|
||||
useAddUser: () => ({ mutate: mockAddUserMutate }),
|
||||
}));
|
||||
|
||||
jest.mock("@/customization/components/custom-link", () => ({
|
||||
CustomLink: ({ children, to }: { children: React.ReactNode; to: string }) => (
|
||||
<a href={to}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("@/customization/hooks/use-custom-navigate", () => ({
|
||||
useCustomNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock("@/customization/utils/analytics", () => ({
|
||||
track: jest.fn(),
|
||||
}));
|
||||
|
||||
function renderSignUpPage() {
|
||||
return render(<SignUp />);
|
||||
}
|
||||
|
||||
describe("SignUpPage accessibility", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useAlertStore.setState({
|
||||
notificationList: [],
|
||||
tempNotificationList: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("announces_required_field_errors_on_empty_submit", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSignUpPage();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /sign up/i }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Please enter your username"),
|
||||
).toHaveAttribute("role", "alert");
|
||||
expect(screen.getByText("Please enter a password")).toHaveAttribute(
|
||||
"role",
|
||||
"alert",
|
||||
);
|
||||
expect(screen.getByText("Please confirm your password")).toHaveAttribute(
|
||||
"role",
|
||||
"alert",
|
||||
);
|
||||
expect(mockAddUserMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should_have_no_axe_violations", async () => {
|
||||
const { container } = renderSignUpPage();
|
||||
|
||||
expect(await axe(container)).toHaveNoViolations();
|
||||
});
|
||||
|
||||
it("uses_valid_external_labels_for_all_fields", () => {
|
||||
renderSignUpPage();
|
||||
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: /username/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/^Password/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText(/^Confirm your password/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("announces_actionable_password_mismatch_suggestion_after_confirm_blur", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSignUpPage();
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Password"), "first-password");
|
||||
await user.type(
|
||||
screen.getByPlaceholderText("Confirm your password"),
|
||||
"second-password",
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Passwords do not match. Re-enter both passwords so they match.",
|
||||
),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.blur(screen.getByPlaceholderText("Confirm your password"));
|
||||
|
||||
const mismatch = await screen.findByText(
|
||||
"Passwords do not match. Re-enter both passwords so they match.",
|
||||
);
|
||||
expect(mismatch).toHaveAttribute("role", "alert");
|
||||
expect(screen.getByPlaceholderText("Password")).not.toHaveAttribute(
|
||||
"aria-describedby",
|
||||
);
|
||||
expect(
|
||||
screen.getByPlaceholderText("Confirm your password"),
|
||||
).toHaveAttribute("aria-describedby", "signup-confirm-password-error");
|
||||
});
|
||||
|
||||
it("adds_actionable_suggestion_to_server_signup_errors", () => {
|
||||
mockAddUserMutate.mockImplementation((_user, options) => {
|
||||
options.onError({
|
||||
response: { data: { detail: "This username is unavailable." } },
|
||||
});
|
||||
});
|
||||
const { container } = renderSignUpPage();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Username"), {
|
||||
target: { value: "alice" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Password"), {
|
||||
target: { value: "same-password" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Confirm your password"), {
|
||||
target: { value: "same-password" },
|
||||
});
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
|
||||
expect(useAlertStore.getState().tempNotificationList[0]).toMatchObject({
|
||||
title: "Error signing up",
|
||||
type: "error",
|
||||
list: [
|
||||
"This username is unavailable. Use a different username or contact an administrator if you already have an account.",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves_fastapi_validation_error_details_for_signup_errors", () => {
|
||||
mockAddUserMutate.mockImplementation((_user, options) => {
|
||||
options.onError({
|
||||
response: {
|
||||
data: {
|
||||
detail: [
|
||||
{ msg: "Username must be unique" },
|
||||
{ msg: "Password is too short" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
const { container } = renderSignUpPage();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Username"), {
|
||||
target: { value: "alice" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Password"), {
|
||||
target: { value: "same-password" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Confirm your password"), {
|
||||
target: { value: "same-password" },
|
||||
});
|
||||
fireEvent.submit(container.querySelector("form")!);
|
||||
|
||||
expect(useAlertStore.getState().tempNotificationList[0]).toMatchObject({
|
||||
title: "Error signing up",
|
||||
type: "error",
|
||||
list: [
|
||||
"Username must be unique; Password is too short. Use a different username or contact an administrator if you already have an account.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,13 +1,18 @@
|
||||
import * as Form from "@radix-ui/react-form";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LangflowLogo from "@/assets/LangflowLogo.svg?react";
|
||||
import ShadTooltip from "@/components/common/shadTooltipComponent";
|
||||
import InputComponent from "@/components/core/parameterRenderComponent/components/inputComponent";
|
||||
import { extractApiErrorMessage } from "@/controllers/API/helpers/extract-api-error-message";
|
||||
import { useAddUser } from "@/controllers/API/queries/auth";
|
||||
import { CustomLink } from "@/customization/components/custom-link";
|
||||
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
|
||||
import { track } from "@/customization/utils/analytics";
|
||||
import {
|
||||
appendErrorSuggestion,
|
||||
getRequiredFieldError,
|
||||
} from "@/utils/authErrorMessages";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { CONTROL_INPUT_STATE } from "../../constants/constants";
|
||||
@ -21,8 +26,8 @@ import type {
|
||||
export default function SignUp(): JSX.Element {
|
||||
const [inputState, setInputState] =
|
||||
useState<signUpInputStateType>(CONTROL_INPUT_STATE);
|
||||
|
||||
const [isDisabled, setDisableBtn] = useState<boolean>(true);
|
||||
const [submitAttempted, setSubmitAttempted] = useState(false);
|
||||
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { password, cnfPassword, username } = inputState;
|
||||
@ -38,13 +43,6 @@ export default function SignUp(): JSX.Element {
|
||||
setInputState((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (password !== cnfPassword) return setDisableBtn(true);
|
||||
if (password === "" || cnfPassword === "") return setDisableBtn(true);
|
||||
if (username === "") return setDisableBtn(true);
|
||||
setDisableBtn(false);
|
||||
}, [password, cnfPassword, username, handleInput]);
|
||||
|
||||
function handleSignup(): void {
|
||||
const { username, password } = inputState;
|
||||
const newUser: UserInputType = {
|
||||
@ -61,29 +59,72 @@ export default function SignUp(): JSX.Element {
|
||||
navigate("/login");
|
||||
},
|
||||
onError: (error) => {
|
||||
const {
|
||||
response: {
|
||||
data: { detail },
|
||||
},
|
||||
} = error;
|
||||
setErrorData({
|
||||
title: t("errors.signup"),
|
||||
list: [detail],
|
||||
list: [
|
||||
appendErrorSuggestion(
|
||||
extractApiErrorMessage(
|
||||
error as Parameters<typeof extractApiErrorMessage>[0],
|
||||
t("errors.signup"),
|
||||
),
|
||||
t("errors.signupSuggestion", {
|
||||
defaultValue:
|
||||
"Use a different username or contact an administrator if you already have an account.",
|
||||
}),
|
||||
),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const passwordMismatch =
|
||||
password !== "" && cnfPassword !== "" && password !== cnfPassword;
|
||||
const usernameError = getRequiredFieldError(
|
||||
submitAttempted,
|
||||
username,
|
||||
t("auth.usernameRequired"),
|
||||
);
|
||||
const passwordError = getRequiredFieldError(
|
||||
submitAttempted,
|
||||
password,
|
||||
t("auth.passwordEnterRequired"),
|
||||
);
|
||||
const shouldShowPasswordMismatch =
|
||||
passwordMismatch && (submitAttempted || confirmPasswordTouched);
|
||||
const confirmPasswordRequiredError = getRequiredFieldError(
|
||||
submitAttempted,
|
||||
cnfPassword,
|
||||
t("auth.confirmPasswordRequired"),
|
||||
);
|
||||
const confirmPasswordError =
|
||||
confirmPasswordRequiredError ??
|
||||
(shouldShowPasswordMismatch
|
||||
? `${t("errors.passwordMismatch")}. ${t(
|
||||
"errors.passwordMismatchSuggestion",
|
||||
{
|
||||
defaultValue: "Re-enter both passwords so they match.",
|
||||
},
|
||||
)}`
|
||||
: undefined);
|
||||
|
||||
return (
|
||||
<Form.Root
|
||||
onInvalidCapture={() => setSubmitAttempted(true)}
|
||||
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
||||
if (password === "") {
|
||||
setSubmitAttempted(true);
|
||||
if (
|
||||
username.trim() === "" ||
|
||||
password.trim() === "" ||
|
||||
cnfPassword.trim() === "" ||
|
||||
passwordMismatch
|
||||
) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const _data = Object.fromEntries(new FormData(event.currentTarget));
|
||||
event.preventDefault();
|
||||
handleSignup();
|
||||
}}
|
||||
className="h-screen w-full"
|
||||
>
|
||||
@ -98,7 +139,12 @@ export default function SignUp(): JSX.Element {
|
||||
</span>
|
||||
<div className="mb-3 w-full">
|
||||
<Form.Field name="username">
|
||||
<Form.Label className="data-[invalid]:label-invalid flex items-center gap-1 overflow-hidden">
|
||||
<label
|
||||
htmlFor="signup-username"
|
||||
className={`flex items-center gap-1 overflow-hidden ${
|
||||
usernameError ? "label-invalid" : ""
|
||||
}`}
|
||||
>
|
||||
<ShadTooltip
|
||||
content={t("auth.usernameLabel")}
|
||||
styleClasses="z-50"
|
||||
@ -106,29 +152,45 @@ export default function SignUp(): JSX.Element {
|
||||
<span className="truncate">{t("auth.usernameLabel")}</span>
|
||||
</ShadTooltip>
|
||||
<span className="shrink-0 font-medium text-destructive">*</span>
|
||||
</Form.Label>
|
||||
</label>
|
||||
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
type="username"
|
||||
onChange={({ target: { value } }) => {
|
||||
handleInput({ target: { name: "username", value } });
|
||||
}}
|
||||
value={username}
|
||||
className="w-full"
|
||||
required
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
/>
|
||||
</Form.Control>
|
||||
<Input
|
||||
id="signup-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
onChange={({ target: { value } }) => {
|
||||
handleInput({ target: { name: "username", value } });
|
||||
}}
|
||||
value={username}
|
||||
className="w-full"
|
||||
required
|
||||
aria-describedby={
|
||||
usernameError ? "signup-username-error" : undefined
|
||||
}
|
||||
aria-invalid={Boolean(usernameError)}
|
||||
placeholder={t("auth.usernamePlaceholder")}
|
||||
/>
|
||||
|
||||
<Form.Message match="valueMissing" className="field-invalid">
|
||||
{t("auth.usernameRequired")}
|
||||
</Form.Message>
|
||||
{usernameError && (
|
||||
<p
|
||||
id="signup-username-error"
|
||||
role="alert"
|
||||
className="field-invalid"
|
||||
>
|
||||
{usernameError}
|
||||
</p>
|
||||
)}
|
||||
</Form.Field>
|
||||
</div>
|
||||
<div className="mb-3 w-full">
|
||||
<Form.Field name="password" serverInvalid={password != cnfPassword}>
|
||||
<Form.Label className="data-[invalid]:label-invalid flex items-center gap-1 overflow-hidden">
|
||||
<Form.Field name="password" serverInvalid={Boolean(passwordError)}>
|
||||
<label
|
||||
htmlFor="form-signup-password"
|
||||
className={`flex items-center gap-1 overflow-hidden ${
|
||||
passwordError ? "label-invalid" : ""
|
||||
}`}
|
||||
>
|
||||
<ShadTooltip
|
||||
content={t("auth.passwordLabel")}
|
||||
styleClasses="z-50"
|
||||
@ -136,7 +198,7 @@ export default function SignUp(): JSX.Element {
|
||||
<span className="truncate">{t("auth.passwordLabel")}</span>
|
||||
</ShadTooltip>
|
||||
<span className="shrink-0 font-medium text-destructive">*</span>
|
||||
</Form.Label>
|
||||
</label>
|
||||
<InputComponent
|
||||
onChange={(value) => {
|
||||
handleInput({ target: { name: "password", value } });
|
||||
@ -145,27 +207,39 @@ export default function SignUp(): JSX.Element {
|
||||
isForm
|
||||
password={true}
|
||||
required
|
||||
id="signup-password"
|
||||
inputProps={{
|
||||
"aria-describedby": passwordError
|
||||
? "signup-password-error"
|
||||
: undefined,
|
||||
"aria-invalid": Boolean(passwordError) || undefined,
|
||||
}}
|
||||
placeholder={t("auth.passwordPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Form.Message className="field-invalid" match="valueMissing">
|
||||
{t("auth.passwordEnterRequired")}
|
||||
</Form.Message>
|
||||
|
||||
{password != cnfPassword && (
|
||||
<Form.Message className="field-invalid">
|
||||
{t("errors.passwordMismatch")}
|
||||
</Form.Message>
|
||||
{passwordError && (
|
||||
<p
|
||||
id="signup-password-error"
|
||||
role="alert"
|
||||
className="field-invalid"
|
||||
>
|
||||
{passwordError}
|
||||
</p>
|
||||
)}
|
||||
</Form.Field>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Form.Field
|
||||
name="confirmpassword"
|
||||
serverInvalid={password != cnfPassword}
|
||||
serverInvalid={Boolean(confirmPasswordError)}
|
||||
>
|
||||
<Form.Label className="data-[invalid]:label-invalid flex items-center gap-1 overflow-hidden">
|
||||
<label
|
||||
htmlFor="form-signup-confirm-password"
|
||||
className={`flex items-center gap-1 overflow-hidden ${
|
||||
confirmPasswordError ? "label-invalid" : ""
|
||||
}`}
|
||||
>
|
||||
<ShadTooltip
|
||||
content={t("auth.confirmPasswordLabel")}
|
||||
styleClasses="z-50"
|
||||
@ -175,35 +249,42 @@ export default function SignUp(): JSX.Element {
|
||||
</span>
|
||||
</ShadTooltip>
|
||||
<span className="shrink-0 font-medium text-destructive">*</span>
|
||||
</Form.Label>
|
||||
</label>
|
||||
|
||||
<InputComponent
|
||||
onChange={(value) => {
|
||||
handleInput({ target: { name: "cnfPassword", value } });
|
||||
}}
|
||||
onBlur={() => setConfirmPasswordTouched(true)}
|
||||
value={cnfPassword}
|
||||
isForm
|
||||
password={true}
|
||||
required
|
||||
id="signup-confirm-password"
|
||||
inputProps={{
|
||||
"aria-describedby": confirmPasswordError
|
||||
? "signup-confirm-password-error"
|
||||
: undefined,
|
||||
"aria-invalid": Boolean(confirmPasswordError) || undefined,
|
||||
}}
|
||||
placeholder={t("auth.confirmPasswordPlaceholder")}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
<Form.Message className="field-invalid" match="valueMissing">
|
||||
{t("auth.confirmPasswordRequired")}
|
||||
</Form.Message>
|
||||
{confirmPasswordError && (
|
||||
<p
|
||||
id="signup-confirm-password-error"
|
||||
role="alert"
|
||||
className="field-invalid"
|
||||
>
|
||||
{confirmPasswordError}
|
||||
</p>
|
||||
)}
|
||||
</Form.Field>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Form.Submit asChild>
|
||||
<Button
|
||||
disabled={isDisabled}
|
||||
type="submit"
|
||||
className="mr-3 mt-6 w-full"
|
||||
onClick={() => {
|
||||
handleSignup();
|
||||
}}
|
||||
>
|
||||
<Button type="submit" className="mr-3 mt-6 w-full">
|
||||
{t("auth.signupButton")}
|
||||
</Button>
|
||||
</Form.Submit>
|
||||
|
||||
@ -1240,7 +1240,7 @@ input[type="password"]::-ms-clear {
|
||||
@apply top-[-23px];
|
||||
}
|
||||
.popover-input {
|
||||
@apply h-fit w-fit flex-1 border-none bg-transparent p-0 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-muted-foreground focus:border-foreground focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50;
|
||||
@apply h-fit w-fit flex-1 border-none bg-transparent p-0 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-muted-foreground focus:border-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
|
||||
.beta-badge {
|
||||
@ -1295,14 +1295,6 @@ input[type="password"]::-ms-clear {
|
||||
@apply flex h-[32px] w-[32px] items-center justify-center rounded-md bg-muted font-bold transition-all;
|
||||
}
|
||||
|
||||
.no-focus-visible {
|
||||
@apply focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0;
|
||||
--tw-ring-offset-width: none !important;
|
||||
--tw-ring-shadow: none !important;
|
||||
--tw-ring-offset-shadow: none !important;
|
||||
--tw-ring-color: none !important;
|
||||
}
|
||||
|
||||
.helper-lines {
|
||||
@apply pointer-events-none absolute left-0 top-0 z-10 h-full w-full;
|
||||
}
|
||||
|
||||
@ -26,14 +26,14 @@
|
||||
--secondary-foreground: 240 4% 16%; /* hsl(240, 4%, 16%) */
|
||||
--accent: 240 5% 96%; /* hsl(240, 5%, 96%) */
|
||||
--accent-foreground: 0 0% 0%; /* hsl(0, 0%, 0%) */
|
||||
--destructive: 0 72% 51%; /* hsl(0, 72%, 51%) */
|
||||
--destructive: 0 72% 45%; /* hsl(0, 72%, 45%) */
|
||||
--destructive-foreground: 0 0% 100%; /* hsl(0, 0%, 100%) */
|
||||
--accent-amber: 26 90% 37%; /* hsl(26, 90%, 37%) */
|
||||
--accent-amber-foreground: 26 90% 37%; /* hsl(26, 90%, 37%) */
|
||||
--ring: 0 0% 0%; /* hsl(0, 0%, 0%) */
|
||||
--primary-hover: 240 4% 16%; /* hsl(240, 4%, 16%) */
|
||||
--secondary-hover: 240 6% 90%; /* hsl(240, 6%, 90%) */
|
||||
--placeholder-foreground: 240 5% 65%; /* hsl(240, 5%, 65%) */
|
||||
--placeholder-foreground: 240 4% 42%; /* hsl(240, 4%, 42%) */
|
||||
--canvas: 240 5% 96%; /* hsl(240, 5%, 96%) */
|
||||
--canvas-dot: 240 5% 65%; /* hsl(240, 5%, 65%) */
|
||||
--accent-emerald: 149 80% 90%; /* hsl(149, 80%, 90%) */
|
||||
@ -240,7 +240,7 @@
|
||||
--ring: 0 0% 100%; /* hsl(0, 0%, 100%) */
|
||||
--primary-hover: 240 6% 90%; /* hsl(240, 6%, 90%) */
|
||||
--secondary-hover: 240 4% 16%; /* hsl(240, 4%, 16%) */
|
||||
--placeholder-foreground: 240 4% 46%; /* hsl(240, 4%, 46%) */
|
||||
--placeholder-foreground: 240 5% 65%; /* hsl(240, 5%, 65%) */
|
||||
--canvas: 0 0% 0%; /* hsl(0, 0%, 0%) */
|
||||
--canvas-dot: 240 5.3% 26.1%; /* hsl(240, 5.3%, 26.1%) */
|
||||
|
||||
@ -493,3 +493,11 @@
|
||||
--indigo-foreground: 234.5 89.5% 73.9%;
|
||||
}
|
||||
}
|
||||
|
||||
/* WCAG 2.4.7 — visible focus indicator fallback for any element not covered
|
||||
by component-level Tailwind ring classes. Component ring classes (box-shadow)
|
||||
take precedence over this outline via the cascade. */
|
||||
:focus-visible {
|
||||
outline: 2px solid hsl(var(--ring));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ReactFlowJsonObject } from "@xyflow/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import type { InputHTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import type { handleOnNewValueType } from "@/CustomNodes/hooks/use-handle-new-value";
|
||||
import type { InputOutput } from "../../constants/enums";
|
||||
import type {
|
||||
@ -32,6 +32,7 @@ export type InputComponentType = {
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
inputProps?: InputHTMLAttributes<HTMLInputElement>;
|
||||
blurOnEnter?: boolean;
|
||||
optionsIcon?: string;
|
||||
optionsPlaceholder?: string;
|
||||
|
||||
19
src/frontend/src/utils/authErrorMessages.ts
Normal file
19
src/frontend/src/utils/authErrorMessages.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export function appendErrorSuggestion(
|
||||
detail: string | undefined,
|
||||
suggestion: string,
|
||||
): string {
|
||||
if (!detail) return suggestion;
|
||||
if (detail.includes(suggestion)) return detail;
|
||||
|
||||
const trimmedDetail = detail.trim();
|
||||
const separator = /[.!?]$/.test(trimmedDetail) ? " " : ". ";
|
||||
return `${trimmedDetail}${separator}${suggestion}`;
|
||||
}
|
||||
|
||||
export function getRequiredFieldError(
|
||||
shouldValidate: boolean,
|
||||
value: string,
|
||||
message: string,
|
||||
): string | undefined {
|
||||
return shouldValidate && value.trim() === "" ? message : undefined;
|
||||
}
|
||||
@ -480,10 +480,6 @@ const config = {
|
||||
".text-align-last-right": {
|
||||
"text-align-last": "right",
|
||||
},
|
||||
":focus-visible": {
|
||||
outline: "none !important",
|
||||
outlineOffset: "0px !important",
|
||||
},
|
||||
".note-node-markdown": {
|
||||
lineHeight: "1",
|
||||
"& ul li::marker": {
|
||||
|
||||
171
src/frontend/tests/a11y/auth-pages.a11y.spec.ts
Normal file
171
src/frontend/tests/a11y/auth-pages.a11y.spec.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { expect, type LangflowPage, test } from "../fixtures";
|
||||
|
||||
async function disableAutoLogin(page: LangflowPage) {
|
||||
await page.route("**/api/v1/auto_login", (route) => {
|
||||
route.fulfill({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
detail: { message: "Auto login is disabled.", auto_login: false },
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function disableAnimations(page: LangflowPage) {
|
||||
await page.addStyleTag({
|
||||
content: `
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0s !important;
|
||||
animation-delay: 0s !important;
|
||||
transition-duration: 0s !important;
|
||||
transition-delay: 0s !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
async function mockLoginError(page: LangflowPage) {
|
||||
await page.route("**/api/v1/login", (route) => {
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "Incorrect username or password." }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mockSignupError(page: LangflowPage) {
|
||||
await page.route("**/api/v1/users/", (route) => {
|
||||
route.fulfill({
|
||||
status: 400,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ detail: "This username is unavailable." }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function forceDarkTheme(page: LangflowPage) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("themePreference", "dark");
|
||||
window.localStorage.setItem("isDark", "true");
|
||||
});
|
||||
}
|
||||
|
||||
async function forceLightTheme(page: LangflowPage) {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("themePreference", "light");
|
||||
window.localStorage.setItem("isDark", "false");
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("auth page accessibility", () => {
|
||||
test("scans empty login", { tag: ["@a11y"] }, async ({ page }) => {
|
||||
await forceLightTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await disableAnimations(page);
|
||||
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
|
||||
await page.runA11yScan("auth-login-empty");
|
||||
});
|
||||
|
||||
test("scans login validation", { tag: ["@a11y"] }, async ({ page }) => {
|
||||
await forceLightTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await disableAnimations(page);
|
||||
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await expect(page.getByRole("alert").first()).toBeVisible();
|
||||
await page.runA11yScan("auth-login-validation");
|
||||
});
|
||||
|
||||
test("scans login error toast", { tag: ["@a11y"] }, async ({ page }) => {
|
||||
await forceLightTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
await mockLoginError(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await disableAnimations(page);
|
||||
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: /^Username \*$/ }).fill("alice");
|
||||
await page.getByLabel(/^Password/).fill("wrong-password");
|
||||
await page.getByRole("button", { name: /sign in/i }).click();
|
||||
await expect(page.getByText("Error signing in")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"Incorrect username or password. Check your username and password, then try again.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.runA11yScan("auth-login-error-toast");
|
||||
});
|
||||
|
||||
test("scans empty signup", { tag: ["@a11y"] }, async ({ page }) => {
|
||||
await forceLightTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
await page.goto("/signup");
|
||||
await disableAnimations(page);
|
||||
await expect(page.getByRole("button", { name: /sign up/i })).toBeVisible();
|
||||
await page.runA11yScan("auth-signup-empty");
|
||||
});
|
||||
|
||||
test(
|
||||
"scans signup password mismatch",
|
||||
{ tag: ["@a11y"] },
|
||||
async ({ page }) => {
|
||||
await forceLightTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
|
||||
await page.goto("/signup");
|
||||
await disableAnimations(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /sign up/i }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel(/^Password/).fill("first-password");
|
||||
await page.getByLabel(/^Confirm your password/).fill("second-password");
|
||||
await page.getByLabel(/^Confirm your password/).blur();
|
||||
await expect(page.getByText(/Passwords do not match/)).toContainText(
|
||||
"Passwords do not match",
|
||||
);
|
||||
await page.runA11yScan("auth-signup-mismatch");
|
||||
},
|
||||
);
|
||||
|
||||
test("scans signup error toast", { tag: ["@a11y"] }, async ({ page }) => {
|
||||
await forceLightTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
await mockSignupError(page);
|
||||
|
||||
await page.goto("/signup");
|
||||
await disableAnimations(page);
|
||||
await expect(page.getByRole("button", { name: /sign up/i })).toBeVisible();
|
||||
await page.getByRole("textbox", { name: /^Username \*$/ }).fill("alice");
|
||||
await page.getByLabel(/^Password/).fill("same-password");
|
||||
await page.getByLabel(/^Confirm your password/).fill("same-password");
|
||||
await page.getByRole("button", { name: /sign up/i }).click();
|
||||
await expect(page.getByText("Error signing up")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
"This username is unavailable. Use a different username or contact an administrator if you already have an account.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await page.runA11yScan("auth-signup-error-toast");
|
||||
});
|
||||
|
||||
test("scans login in dark mode", { tag: ["@a11y"] }, async ({ page }) => {
|
||||
await forceDarkTheme(page);
|
||||
await disableAutoLogin(page);
|
||||
|
||||
await page.goto("/login");
|
||||
await disableAnimations(page);
|
||||
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
|
||||
await expect(page.locator("body")).toHaveClass(/dark/);
|
||||
|
||||
await page.runA11yScan("auth-login-dark-empty");
|
||||
});
|
||||
});
|
||||
120
src/frontend/tests/extended/features/focus-visible.spec.ts
Normal file
120
src/frontend/tests/extended/features/focus-visible.spec.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { expect, test } from "../../fixtures";
|
||||
|
||||
/**
|
||||
* WCAG 2.4.7 Focus Visible regression tests.
|
||||
*
|
||||
* Each test tabs to an interactive element and confirms it has a visible
|
||||
* focus indicator — either a non-zero outline or a non-"none" box-shadow
|
||||
* (Tailwind ring classes render as box-shadow).
|
||||
*/
|
||||
|
||||
async function getFocusStyle(page) {
|
||||
return page.evaluate(() => {
|
||||
const el = document.activeElement as HTMLElement | null;
|
||||
if (!el) return null;
|
||||
const style = window.getComputedStyle(el);
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
testId: el.getAttribute("data-testid") ?? "",
|
||||
outlineWidth: style.outlineWidth,
|
||||
outlineStyle: style.outlineStyle,
|
||||
boxShadow: style.boxShadow,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function hasFocusIndicator(focusStyle: {
|
||||
outlineWidth: string;
|
||||
outlineStyle: string;
|
||||
boxShadow: string;
|
||||
}) {
|
||||
const hasOutline =
|
||||
focusStyle.outlineStyle !== "none" &&
|
||||
focusStyle.outlineWidth !== "0px" &&
|
||||
focusStyle.outlineWidth !== "";
|
||||
const hasRing =
|
||||
focusStyle.boxShadow !== "none" && focusStyle.boxShadow !== "";
|
||||
return hasOutline || hasRing;
|
||||
}
|
||||
|
||||
test(
|
||||
"login page — every interactive element shows a visible focus indicator when tabbed to",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Tab through up to 20 focusable elements
|
||||
const violations: string[] = [];
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await page.keyboard.press("Tab");
|
||||
const focusStyle = await getFocusStyle(page);
|
||||
if (!focusStyle) continue;
|
||||
|
||||
// Skip elements that are intentionally not interactive (body, html)
|
||||
if (["body", "html"].includes(focusStyle.tag)) continue;
|
||||
|
||||
if (!hasFocusIndicator(focusStyle)) {
|
||||
violations.push(
|
||||
`Element <${focusStyle.tag}> data-testid="${focusStyle.testId}" has no visible focus indicator (outline: ${focusStyle.outlineWidth} ${focusStyle.outlineStyle}, box-shadow: ${focusStyle.boxShadow})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
violations,
|
||||
`Focus visible violations found:\n${violations.join("\n")}`,
|
||||
).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"canvas controls — add note, zoom, and fit view buttons show focus ring when tabbed to",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const controlTestIds = [
|
||||
"canvas-add-note-button",
|
||||
"zoom_in",
|
||||
"zoom_out",
|
||||
"fit_view",
|
||||
];
|
||||
|
||||
for (const testId of controlTestIds) {
|
||||
const button = page.getByTestId(testId);
|
||||
if (!(await button.isVisible())) continue;
|
||||
|
||||
await button.focus();
|
||||
const focusStyle = await getFocusStyle(page);
|
||||
if (!focusStyle) continue;
|
||||
|
||||
expect(
|
||||
hasFocusIndicator(focusStyle),
|
||||
`Canvas control [data-testid="${testId}"] has no visible focus indicator`,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
"dropdown trigger shows focus ring on keyboard focus",
|
||||
{ tag: ["@release", "@workspace"] },
|
||||
async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Find any visible button-role element and tab to it
|
||||
const triggers = page.locator('[role="combobox"]:visible').first();
|
||||
if (!(await triggers.isVisible())) return;
|
||||
|
||||
await triggers.focus();
|
||||
const focusStyle = await getFocusStyle(page);
|
||||
|
||||
expect(
|
||||
focusStyle && hasFocusIndicator(focusStyle),
|
||||
`Dropdown trigger has no visible focus indicator (outline: ${focusStyle?.outlineWidth}, box-shadow: ${focusStyle?.boxShadow})`,
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user