leptos_captcha/
lib.rs

1// Copyright 2025 Sebastian Dobe <sebastiandobe@mailbox.org>
2
3#![doc = include_str!("../README.md")]
4
5use core::future::Future;
6use leptos::{logging::log, prelude::*, task::spawn_local};
7
8// re-export the Pow for ease of use
9pub use spow;
10
11pub fn pow_dispatch<C, F, Fut>(get_pow: F, is_pending: RwSignal<Option<bool>>, callback: C)
12where
13    C: FnOnce(Result<String, ServerFnError>) + 'static,
14    F: FnOnce() -> Fut + 'static,
15    Fut: Future<Output = Result<String, ServerFnError>>,
16{
17    is_pending.set(Some(true));
18    spawn_local(async move {
19        match get_pow().await {
20            Ok(challenge) => {
21                log!("PoW challenge: {}", challenge);
22                #[cfg(target_arch = "wasm32")]
23                let work = spow::wasm::pow_work(&challenge).unwrap();
24                #[cfg(not(target_arch = "wasm32"))]
25                let work = spow::pow::Pow::work(&challenge).unwrap();
26                is_pending.set(Some(false));
27                callback(Ok(work));
28            }
29            Err(err) => {
30                callback(Err(err))
31            },
32        }
33    });
34}
35
36#[component]
37pub fn Captcha(
38    is_pending: RwSignal<Option<bool>>,
39    #[prop(default = "Not a Robot")] text: &'static str,
40    #[prop(default = "Verifying")] text_pending: &'static str,
41    #[prop(default = "Verified")] text_verified: &'static str,
42) -> impl IntoView {
43    let data_state = move || match is_pending.get() {
44        None => "",
45        Some(true) => "pending",
46        Some(false) => "verified",
47    };
48
49    view! {
50        <div class="leptos-captcha" data-state=data_state>
51            <label>
52                <input type="hidden" name="pow" value="" />
53                {move || match is_pending.get() {
54                    None => view! {
55                        <div class="icon-front">
56                            <ShieldExclamation />
57                        </div>
58                        <div class="text">
59                            {text}
60                        </div>
61                    }.into_any(),
62                    Some(true) => view! {
63                        <div class="icon-front">
64                            <ShieldExclamation />
65                        </div>
66                        <div class="text pending">
67                            {text_pending}
68                        </div>
69                        <div class="spinner"><div></div><div></div><div></div><div></div></div>
70                    }.into_any(),
71                    Some(false) => view! {
72                        <div class="icon-front">
73                            <ShieldCheck />
74                        </div>
75                        <div class="text verified">
76                            {text_verified}
77                        </div>
78                        <div class="icon-back">
79                            <IconCheck />
80                        </div>
81                    }.into_any(),
82                }}
83            </label>
84        </div>
85    }
86}
87
88#[component]
89fn ShieldExclamation() -> impl IntoView {
90    view! {
91        <svg
92            xmlns="http://www.w3.org/2000/svg"
93            viewBox="0 0 24 24"
94            fill="currentColor"
95            class="w-6 h-6"
96        >
97            <path
98                fill-rule="evenodd"
99                d="M11.484 2.17a.75.75 0 0 1 1.032 0 11.209 11.209 0 0 0 7.877 3.08.75.75 0 0 \
100                1 .722.515 12.74 12.74 0 0 1 .635 3.985c0 5.942-4.064 10.933-9.563 12.348a.749.749 0 \
101                0 1-.374 0C6.314 20.683 2.25 15.692 2.25 9.75c0-1.39.223-2.73.635-3.985a.75.75 0 0 \
102                1 .722-.516l.143.001c2.996 0 5.718-1.17 7.734-3.08ZM12 8.25a.75.75 0 0 1 \
103                .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75ZM12 15a.75.75 0 0 0-.75.75v.008c0 \
104                .414.336.75.75.75h.008a.75.75 0 0 0 .75-.75v-.008a.75.75 0 0 0-.75-.75H12Z"
105                clip-rule="evenodd"
106            />
107        </svg>
108    }
109}
110
111#[component]
112fn ShieldCheck() -> impl IntoView {
113    view! {
114        <svg
115            xmlns="http://www.w3.org/2000/svg"
116            viewBox="0 0 24 24"
117            fill="currentColor"
118            class="w-6 h-6"
119        >
120            <path
121                fill-rule="evenodd"
122                d="M12.516 2.17a.75.75 0 0 0-1.032 0 11.209 11.209 0 0 1-7.877 3.08.75.75 0 0 \
123                0-.722.515A12.74 12.74 0 0 0 2.25 9.75c0 5.942 4.064 10.933 9.563 12.348a.749.749 \
124                0 0 0 .374 0c5.499-1.415 9.563-6.406 9.563-12.348 0-1.39-.223-2.73-.635-3.985a.75.75 \
125                0 0 0-.722-.516l-.143.001c-2.996 0-5.717-1.17-7.734-3.08Zm3.094 8.016a.75.75 0 1 \
126                0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 \
127                1.14-.094l3.75-5.25Z"
128                clip-rule="evenodd"
129            />
130        </svg>
131    }
132}
133
134#[component]
135pub fn IconCheck() -> impl IntoView {
136    view! {
137        <svg
138            fill="none"
139            viewBox="0 0 24 24"
140            stroke="currentColor"
141            stroke-width=2
142        >
143            <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
144        </svg>
145    }
146}