#[derive(Debug, Clone, PartialEq, Default, Copy)]
pub enum Variant {
#[default]
Primary,
Secondary,
}
impl Variant {
pub fn to_container_class(self) -> &'static str {
match self {
Self::Primary => "otp",
Self::Secondary => "otp otp--secondary",
}
}
pub fn to_slot_class(self) -> &'static str {
match self {
Self::Primary => "otp__slot",
Self::Secondary => "otp__slot otp__slot--secondary",
}
}
}
#[derive(Debug, Clone, PartialEq, Default, Copy)]
pub enum InputMode {
#[default]
Numeric,
Text,
AlphaNumeric,
}
impl InputMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Numeric => "numeric",
Self::Text => "text",
Self::AlphaNumeric => "text",
}
}
}
pub mod pattern {
pub const DIGITS_ONLY: &str = r"[0-9]";
pub const CHARS_ONLY: &str = r"[a-zA-Z]";
pub const DIGITS_AND_CHARS: &str = r"[0-9a-zA-Z]";
}
#[derive(Debug, Clone, PartialEq)]
pub enum OtpValidationError {
InvalidLength {
got: usize,
expected: usize,
},
InvalidCharacter(char),
TotpExpired,
HotpCounterMismatch,
}
impl core::fmt::Display for OtpValidationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::InvalidLength { got, expected } => {
write!(f, "OTP length mismatch: got {got}, expected {expected}")
}
Self::InvalidCharacter(c) => write!(f, "OTP contains invalid character: {c:?}"),
Self::TotpExpired => write!(f, "TOTP code has expired or is invalid"),
Self::HotpCounterMismatch => write!(f, "HOTP code does not match for given counter"),
}
}
}
fn make_totp(secret: &[u8], digits: u8, period: u64) -> totp_rs::Totp {
use totp_rs::{Builder, Secret};
Builder::new()
.with_secret(Secret::from(secret))
.with_digits(digits)
.with_step_duration(period)
.build_noncompliant()
}
pub fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
make_totp(secret, digits as u8, 1)
.generate(counter)
.to_string()
}
pub fn validate_hotp(
secret: &[u8],
counter: u64,
code: &str,
digits: u32,
) -> Result<(), OtpValidationError> {
if code.len() != digits as usize {
return Err(OtpValidationError::InvalidLength {
got: code.len(),
expected: digits as usize,
});
}
for c in code.chars() {
if !c.is_ascii_digit() {
return Err(OtpValidationError::InvalidCharacter(c));
}
}
if make_totp(secret, digits as u8, 1)
.generate(counter)
.to_string()
== code
{
Ok(())
} else {
Err(OtpValidationError::HotpCounterMismatch)
}
}
pub fn validate_totp(
secret: &[u8],
unix_now: u64,
code: &str,
digits: u32,
period: u64,
) -> Result<(), OtpValidationError> {
if code.len() != digits as usize {
return Err(OtpValidationError::InvalidLength {
got: code.len(),
expected: digits as usize,
});
}
for c in code.chars() {
if !c.is_ascii_digit() {
return Err(OtpValidationError::InvalidCharacter(c));
}
}
let totp = make_totp(secret, digits as u8, period);
let step = unix_now / period;
for delta in 0u64..=2 {
let t = step.saturating_sub(1).wrapping_add(delta) * period;
if totp.generate(t).to_string() == code {
return Ok(());
}
}
Err(OtpValidationError::TotpExpired)
}
pub fn generate_totp(secret: &[u8], unix_now: u64, digits: u32, period: u64) -> String {
make_totp(secret, digits as u8, period)
.generate(unix_now)
.to_string()
}
pub fn validate_totp_stack(
secret: &[u8; 20],
unix_now: u64,
code: &str,
digits: u32,
period: u64,
) -> Result<(), OtpValidationError> {
validate_totp(secret.as_slice(), unix_now, code, digits, period)
}
pub fn generate_totp_stack(secret: &[u8; 20], unix_now: u64, digits: u32, period: u64) -> String {
generate_totp(secret.as_slice(), unix_now, digits, period)
}
#[cfg(any(feature = "lep", feature = "dio", feature = "yew"))]
pub fn unix_now_secs() -> u64 {
(js_sys::Date::now() / 1000.0) as u64
}
pub fn base_otp_style() -> &'static str {
"display: inline-flex; align-items: center; gap: 12px; position: relative;"
}
pub fn base_otp_inner_style() -> &'static str {
"display: inline-flex; align-items: center; gap: 8px;"
}
pub fn base_slot_style() -> &'static str {
"display: inline-flex; align-items: center; justify-content: center; \
width: 52px; height: 52px; min-width: 52px; min-height: 52px; flex-shrink: 0; \
border-radius: 12px; border: 2px solid #3f3f46; background-color: #18181b; \
color: #fafafa; font-size: 22px; font-weight: 700; font-family: 'Inter', ui-monospace, monospace; \
line-height: 1; letter-spacing: 0; cursor: text; outline: none; \
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease; \
position: relative; overflow: hidden; user-select: none; box-sizing: border-box;"
}
pub fn slot_active_style() -> &'static str {
"border-color: #7c3aed; box-shadow: 0 0 0 3px rgba(124,58,237,0.25);"
}
pub fn slot_filled_style() -> &'static str {
"border-color: #6d28d9; background-color: #1c1c27;"
}
pub fn slot_invalid_style() -> &'static str {
"border-color: #dc2626; box-shadow: 0 0 0 3px rgba(220,38,38,0.2); animation: otp-shake 0.35s ease;"
}
pub fn slot_disabled_style() -> &'static str {
"border-color: #27272a; background-color: #111113; color: #52525b; cursor: not-allowed; opacity: 0.6;"
}
pub fn caret_style() -> &'static str {
"position: absolute; width: 2px; height: 60%; background-color: #7c3aed; border-radius: 1px; animation: otp-blink 1s step-start infinite;"
}
pub fn slot_enter_animation_style() -> &'static str {
"animation: otp-pop 0.18s ease; border-color: #7c3aed;"
}
pub fn base_separator_style() -> &'static str {
"width: 12px; height: 2px; background-color: #3f3f46; border-radius: 2px; flex-shrink: 0;"
}