Bỏ qua tới nội dung
Pattern / Auth / NEXOBOX

Tình trạng bảo mật & mã một lần

Live

Hai part của bộ patterns/auth, dựng cho bề mặt đăng nhập Admin theo ADM-D33: một bảng tình trạng chỉ trình bày sự thật do host tính, và một ô mã một lần dùng đúng một <input> thật.

Context & goal

Một nhân sự nội bộ mở trang đăng nhập và cần tin rằng bề mặt này có thật sự bắt buộc hai bước, có thật sự ghi lưu vết, và có thật sự hết hạn sau sáu mươi phút. Cách rẻ nhất để nói điều đó là viết một câu khẩu hiệu — và một khẩu hiệu thì không bao giờ sai được, nên nó cũng không bao giờ đúng được.

Dùng cho cột kể chuyện của một bề mặt auth, khi host có thể đọc lại từng sự thật ở thời điểm render. Không dùng cho một danh sách tính năng, một bảng giá, hay bất cứ nội dung nào không ai kiểm lại được: một dòng không kiểm được phải là unknown, và nếu tất cả các dòng đều như vậy thì đây là component sai.

OneTimeCodeField tồn tại bên cạnh OtpSlots vì hai thứ trả lời hai câu hỏi khác nhau. OtpSlots vẽ sáu ô để mã trông như mã. OneTimeCodeField giữ một input duy nhất để trình quản lý mật khẩu, tính năng tự điền mã của hệ điều hành và trình đọc màn hình đều làm việc được — và đưa phần “trông như mã” xuống một thước nhóm trang trí bên dưới.

Interactive scenario

Số liệu dưới đây là dữ liệu mẫu của trang tài liệu, không phải tình trạng của máy chủ nào. Bản thật được suy ra ở apps/admin/src/app/(auth)/_parts/posture.ts.

  • Phiên tuyệt đối 60 phútĐang áp dụngKhông gia hạn khi còn hoạt động. Hết hạn là đăng nhập lại.
  • Xác thực hai bước bắt buộcĐang áp dụngTOTP, mã dự phòng, hoặc passkey có xác minh người dùng · chứng cứ MFA tối đa 5 phút
  • Mọi lượt đọc được ghi audit trước khi trả dữ liệuĐang áp dụngGhi vào tenant lưu vết đã cấu hình, trong cùng giao dịch với lượt đọc.

Sáu chữ số từ ứng dụng xác thực của bạn.

Thử dán 123 456 khi ở totp, và abcd-efgh-ij khi ở backup: dấu cách, dấu gạch và chữ thường đều được xử lý ở onChange, nên host luôn nhận đúng chuỗi mà máy chủ chờ.

Anatomy

SecurityPostureList: mỗi hàng là dấu trạng thái (một khối 8px trong rãnh cố định) → nhãn khẳng định → chữ verdict → dòng bằng chứng. Các hàng cách nhau bằng một nét tóc 1px, không bằng khoảng trắng: đó là thứ làm khối này đọc ra như một trang sổ cái thay vì một danh sách gạch đầu dòng. Rãnh của dấu trạng thái là lý do mọi nhãn bắt đầu trên cùng một đường dọc.

OneTimeCodeField: Label → Input (một, thật, căn giữa, chữ số tabular) → thước nhóm trang trí → hint → vùng ghi chú. Vùng ghi chú luôn có mặt, kể cả khi không có lỗi: một node chỉ xuất hiện cùng thông báo là một node mà aria-live không kịp theo, và việc nó xuất hiện làm cả biểu mẫu nhảy một dòng.

Thước nhóm nằm dưới ô nhập, không nằm sau chữ. Đặt thước sau chữ sẽ buộc bề rộng một ký tự phải khớp với bề rộng một ô — và nó sẽ lệch ngay khi người dùng phóng to trang hoặc font dự phòng được dùng.

State model

StateTriggerVisible UIAllowed actions / next
SecurityPostureList · okHost xác nhận được sự thật đó.Khối đặc, màu status success, chữ verdict.Không có hành động — đây là bảng đọc.
SecurityPostureList · warnHost kiểm được và thấy KHÔNG đạt.Khối rỗng (chỉ viền), màu status warning.Trang chủ quản quyết định có chuyển sang unavailable hay không.
SecurityPostureList · unknownHost không kiểm được ở lần render này.Một nét gạch ngang, màu chữ phụ.Không được hiển thị như ok. Đây là trạng thái thứ ba, không phải warn nhẹ.
OneTimeCodeField · trốngChưa nhập ký tự nào.Thước nhóm 3-3 hoặc 5-5 đều nhạt.Nhận dán cả chuỗi; ký tự lạ bị lọc, không báo lỗi.
OneTimeCodeField · đang nhậpvalue.length < length.Thước sáng dần từng ô theo số ký tự đã có.Enter gửi form một trường theo hành vi chuẩn của biểu mẫu.
OneTimeCodeField · đủvalue.length === length.Toàn bộ thước sáng.Không tự gửi. Tự gửi khi đủ ký tự sẽ đốt một lần thử vì một lỗi đánh máy.
OneTimeCodeField · lỗierror được truyền vào.aria-invalid, viền lỗi, dòng ghi chú trong vùng aria-live.Giá trị được giữ nguyên để người dùng sửa, không bị xoá.

Không có state loading ở cả hai part. Việc chờ thuộc về host: nút gửi của nó mang loading, và cột mực của nó là nơi có nét chia sáng lên. Một ô nhập tự vẽ trạng thái chờ sẽ nói hai lần cùng một điều.

Permissions & responsibility

Cả hai part không kiểm quyền và không tính verdict. SecurityPostureList nhận state đã tính; nếu nó tự đọc cấu hình thì nó có thể nói khác với máy chủ mà nó đang mô tả, và đó đúng là lỗi mà ADM-D33 được viết ra để chặn. OneTimeCodeField không giữ, không gửi và không xác minh mã; nó lọc hình dạng đầu vào để máy chủ không phải từ chối một chuỗi chỉ vì có dấu cách.

Máy chủ vẫn kiểm lại mọi thứ: độ dài, ký tự cho phép, hạn mức, và việc mã dự phòng chỉ dùng được một lần. Việc lọc ở client là tiện lợi, không phải hàng rào.

Integration contract

type SecurityPostureState = 'ok' | 'warn' | 'unknown';

type SecurityPostureItem = {
  label: string;                 // câu khẳng định, bằng ngôn ngữ của host
  state: SecurityPostureState;   // verdict do host tính, không do component tính
  detail?: ReactNode;            // bằng chứng: một thời lượng, một tên biến, một origin
};

type SecurityPostureListProps = {
  items: SecurityPostureItem[];
  stateLabels: Record<SecurityPostureState, string>;  // bắt buộc: part không mang chữ nào
  tone?: 'inverse' | 'surface';                        // mặc định 'inverse'
  orientation?: 'stack' | 'row';                       // mặc định 'stack'
  label?: string;                 // bắt buộc khi orientation='row' (vùng cuộn nhận focus)
  className?: string;
};
type OneTimeCodeKind = 'totp' | 'backup';

type OneTimeCodeFieldProps = {
  label: string;
  kind: OneTimeCodeKind;   // totp = 6 chữ số, nhóm 3-3 · backup = 10 ký tự, nhóm 5-5
  value: string;
  onChange: (value: string) => void;   // nhận giá trị ĐÃ lọc và đã cắt độ dài
  hint?: string;
  error?: string;
  id?: string; name?: string;
  autoFocus?: boolean; disabled?: boolean; required?: boolean;
};

stateLabels là bắt buộc, không có mặc định. Một mặc định tiếng Việt nằm trong packages/ui sẽ là chữ của sản phẩm lọt vào bộ kit dùng chung, và sẽ là chữ sai ở sản phẩm đầu tiên cần ngôn ngữ khác.

onChange của OneTimeCodeField luôn nhận giá trị đã lọc và đã cắt theo length. Host không cần — và không nên — lọc lại: hai lớp lọc khác nhau là hai cách hiểu khác nhau về mã hợp lệ.

Tokens & composition

SecurityPostureList không dùng primitive nào; nó chỉ dùng token. Dấu trạng thái lấy màu từ status.success.icon và status.warning.icon; ở tone="inverse", trạng thái unknown lấy inverse.text.secondary thay cho status.neutral.icon — neutral là một sắc tối theo thiết kế và sẽ biến mất trên nền mực.

OneTimeCodeField compose Input và Label. Bộ chữ dùng nhóm typography.data kèm font-variant-numeric: tabular-nums. Cần nói rõ: bộ token hiện không có font family monospace — mọi *-font-family trong nexoframe.tokens.css đều trả về “Be Vietnam Pro”. Nên “mã đơn cách” ở đây là chữ số tabular trên bộ chữ thân, chứ không phải monospace thật; thêm một family token là quyết định của lớp token, không phải của part này.

Ba thuộc tính chữ của ô nhập được đặt bằng style inline, có chủ đích: Input đã khai báo font-family, font-size và letter-spacing bằng utility cùng độ đặc hiệu, và giữa hai rule cùng độ đặc hiệu thì thứ tự trong stylesheet quyết định, không phải thứ tự trong className. Mọi giá trị inline đó vẫn là var(--nf-*).

Accessibility & content

  • Ba verdict phân biệt bằng ba thứ: hình khối (đặc / rỗng / gạch), màu token, và chữ stateLabels[state] hiển thị thật. A21: màu không bao giờ là vật mang duy nhất.
  • orientation="row" là một vùng cuộn, nên nó nhận focus và có vòng focus bằng token — một vùng chỉ chuột cuộn được là một vùng bàn phím không đọc được (SC 2.1.1). Vì thế label bắt buộc ở chế độ này.
  • Một <input> duy nhất: một tab stop, một lần đọc nhãn, autocomplete="one-time-code" hoạt động, trình quản lý mật khẩu điền được.
  • aria-describedby trỏ tới cả hint và vùng ghi chú; vùng ghi chú mang aria-live="polite" và luôn được mount, nên không có node nào bị display:none giữa một chuyển trạng thái.
  • Lỗi giữ nguyên giá trị đang có. Nhãn mô tả việc; câu lỗi nói cách khắc phục (A21–A24). Không có chuỗi nào trong hai part này — mọi chữ đến từ props.

Responsive

stack là bố cục cột, dùng từ medium (960px) trở lên trên bề mặt Admin. Dưới mốc đó, cột mực co thành một dải cao 96px và danh sách chuyển sang row: cùng các hàng, bỏ detail, cuộn ngang thay vì đẩy biểu mẫu ra khỏi màn hình điện thoại.

Hai chế độ được render bằng hai instance loại trừ nhau bằng CSS, không phải một instance đổi hướng. Lý do thực dụng: nội dung của dải khác nội dung của cột (dải không có dòng bằng chứng), nên chúng không phải cùng một danh sách được xếp lại.

Ô mã giữ font-size của Input (1rem), tức trên 16px, nên iOS không tự phóng to khi focus.

Release checklist

  • Mọi dòng posture đều truy được về một hàm hoặc một biến; không dòng nào là câu viết tay.
  • Bỏ cấu hình ra khỏi môi trường rồi tải lại: dòng liên quan phải chuyển sang warn, không im lặng giữ ok.
  • Dán “123 456” và “abcd-efgh-ij” vào ô mã: phải thành 123456 và ABCDEFGHIJ.
  • Trình quản lý mật khẩu điền được ô mã (một input thật, autocomplete=one-time-code).
  • Tab đi hết ô mã đúng một lần, không phải sáu lần.
  • Ở orientation='row', Tab tới được vùng cuộn và mũi tên cuộn được nó.
  • Greyscale: ba verdict vẫn phân biệt được bằng hình khối và bằng chữ.
  • prefers-reduced-motion: thước nhóm không nhấp nháy; chỉ có một chuyển màu nền.

Changelog & references

2026-09-30 · A1/007 — hai part được thêm vào packages/ui/src/patterns/auth/ cho bề mặt auth của Admin theo ADM-D33. orientation="row" có mặt ngay từ đầu vì mốc 960px của ADM-D33 cần nó; nó không phải một biến thể thêm sau.

Hợp đồng hành vi: docs-design-system/04_patterns/auth.md, mục “SecurityPostureList và OneTimeCodeField”. Quyết định sản phẩm: ADM-D32 và ADM-D33 trong docs/09_delivery/admin/decision-report-2026-09-28.md. Bản dựng production duy nhất hiện nay là apps/admin route group (auth); trang này là bản dựng tài liệu, không phải bằng chứng vận hành.