import React, { useEffect, useState } from "react"; import { Pressable, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { useColorScheme } from "nativewind"; import { MutedText, Text } from "./text"; import { copyToClipboard } from "../utils/clipboard"; export interface TotpDisplayProps { /** Base32-encoded TOTP secret. Empty string disables the display. */ secret: string; /** * Pure function that computes the current TOTP code from `secret`. * Apps inject their own implementation so this widget stays decoupled * from the crypto module — e.g. vault wires * `@vault/shared/crypto/totp.computeTotpCode`. */ computeCode: (secret: string) => string; /** TOTP step period in seconds. Defaults to 30. */ period?: number; /** Optional callback fired after a successful clipboard write. */ onCopy?: (code: string) => void; } const PLACEHOLDER = "------"; /** * TOTP code display with a circular countdown ring. Recomputes the code * on each 30s window boundary; ticks the remaining-seconds value once * per second to drive the ring animation. */ export function TotpDisplay({ secret, computeCode, period = 30, onCopy, }: TotpDisplayProps): React.JSX.Element | null { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const safeCompute = (s: string): string => { if (!s) { return PLACEHOLDER; } try { return computeCode(s); } catch { return PLACEHOLDER; } }; const [code, setCode] = useState(() => safeCompute(secret)); const [remaining, setRemaining] = useState(() => { const now = Math.floor(Date.now() / 1000); return period - (now % period); }); useEffect(() => { if (!secret) { return; } let lastWindow = -1; const tick = () => { const now = Math.floor(Date.now() / 1000); const window = Math.floor(now / period); setRemaining(period - (now % period)); if (window !== lastWindow) { lastWindow = window; setCode(safeCompute(secret)); } }; tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [secret, period]); if (!secret) { return null; } const copy = async (): Promise => { if (!code || code === PLACEHOLDER) { return; } // Route through the shared helper so the 30s auto-clear applies. // TOTP codes rotate every 30s anyway, but the clipboard residue // still leaks the code to whatever the user pastes next (or to // a shoulder-surfer hitting paste). const ok = await copyToClipboard(code); if (ok) { onCopy?.(code); } }; const urgent = remaining <= 5; const foreground = isDark ? "rgb(255, 255, 255)" : "rgb(0, 0, 0)"; const destructive = "rgb(239, 68, 68)"; const border = isDark ? "rgb(38, 38, 38)" : "rgb(229, 229, 229)"; const card = isDark ? "rgb(0, 0, 0)" : "rgb(255, 255, 255)"; const secondary = isDark ? "rgb(38, 38, 38)" : "rgb(245, 245, 245)"; const accent = urgent ? destructive : foreground; return ( ({ flexDirection: "row", alignItems: "center", gap: 12, padding: 12, borderRadius: 8, borderWidth: 1, borderColor: border, backgroundColor: pressed ? secondary : card, })} > TOTP code {`${code.slice(0, 3)} ${code.slice(3)}`} ); } interface RingProps { remaining: number; total: number; color: string; backgroundColor: string; } function CountdownRing({ remaining, total, color, backgroundColor }: RingProps): React.JSX.Element { const size = 36; const stroke = 3; const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; const fraction = Math.max(0, Math.min(1, remaining / total)); const dashOffset = circumference * (1 - fraction); // Cast raw SVG tags through React.createElement so RN-Web renders them // as real / elements without depending on // `react-native-svg`. Native targets fall back to the View at the // outer level — apps that want native parity should overlay this with // an SVG-backed implementation in their own component layer. const Svg = "svg" as unknown as React.ComponentType>; const Circle = "circle" as unknown as React.ComponentType>; return ( {remaining} ); }