crates/gunnar-ui-web/src/pages/identity_provider.rs
//! **Administration → Identity provider** (company SSO, owner 2026-10-05): an
//! account admin points the account at the company's own identity system —
//! OIDC or SAML 2.0 — and provisions its people (SCIM, or a CSV import).
//!
//! Every value is the server's: `GET /api/account/sso?account=` answers the
//! provider, the SCIM bearers and the directory, to the account's admin alone
//! (gunnar decides who that is); anybody else is shown the refusal. The
//! client secret is sent once and never comes back; a SCIM bearer is shown
//! once, here, and never again.
//!
//! The account is the reader's own (`/api/account/room` names it from the
//! session) unless the page was opened for another one (`?account=`).
use leptos::prelude::*;
use serde::Deserialize;
use crate::ui::{FailBox, Hint, Loading, PageHeader, Panel};
/// `GET /api/account/sso?account=`.
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Shown {
pub account: String,
#[serde(default)]
pub provider: Option<Provider>,
#[serde(default)]
pub scim_base_url: String,
#[serde(default)]
pub login_url: String,
#[serde(default)]
pub redirect_uri: String,
#[serde(default)]
pub acs_url: String,
#[serde(default)]
pub sp_entity_id: String,
#[serde(default)]
pub scim_tokens: Vec<Token>,
#[serde(default)]
pub users: Vec<User>,
#[serde(default)]
pub groups: u32,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Provider {
pub protocol: String,
#[serde(default)]
pub display_name: String,
#[serde(default)]
pub domains: Vec<String>,
/// Each named domain's TXT record and whether gunnar read it (A34-5).
#[serde(default)]
pub domain_proofs: Vec<DomainProof>,
/// Who may become a new employee just in time: open, domains, off (A34-25).
#[serde(default)]
pub jit: String,
#[serde(default)]
pub require_sso: bool,
#[serde(default)]
pub require_passkey: bool,
#[serde(default)]
pub has_client_secret: bool,
#[serde(default)]
pub oidc: Oidc,
#[serde(default)]
pub saml: Saml,
#[serde(default)]
pub sp_metadata_xml: String,
}
/// A domain's DNS proof, as gunnar names it (A34-5).
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DomainProof {
pub domain: String,
#[serde(default)]
pub txt_name: String,
#[serde(default)]
pub txt_value: String,
#[serde(default)]
pub verified: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Oidc {
#[serde(default)]
pub issuer: String,
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub username_claim: String,
/// The SHA-256 fingerprints of the IdP CA gunnar holds for this account.
#[serde(default)]
pub ca_fingerprints: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Saml {
#[serde(default)]
pub metadata_url: String,
#[serde(default)]
pub entity_id: String,
#[serde(default)]
pub sso_url: String,
#[serde(default)]
pub certificate_fingerprints: Vec<String>,
#[serde(default)]
pub username_attribute: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Token {
pub token_id: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub read_only: bool,
/// Gunnar's warning for this bearer (unused, expiring, expired), or empty.
#[serde(default)]
pub attention: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct User {
pub principal_id: String,
pub user_name: String,
#[serde(default)]
pub display_name: String,
pub active: bool,
pub source: String,
pub bound: bool,
/// A rebind is open (A34-11): the next sign-in binds it afresh.
#[serde(default)]
pub rebind: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct Room {
namespace: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct Created {
#[serde(default)]
secret: String,
}
pub use crate::sso_import::{ended, import_line, ImportJob};
/// `?account=` on the page's own address, if any.
fn asked_account() -> Option<String> {
crate::sso_page::account_in(&web_sys::window()?.location().search().ok()?)
}
#[component]
pub fn IdentityProvider() -> impl IntoView {
let account = LocalResource::new(|| async {
match asked_account() {
Some(a) => Ok(a),
None => crate::api::get::<Room>("/api/account/room")
.await
.map(|r| r.namespace),
}
});
view! {
<PageHeader title="Identity provider"/>
<Hint
line="Let your company's people sign in with your own identity system (OIDC or SAML)."
more="Provision them with SCIM or a CSV file. They become NEW users of your account; nobody who already has an account here is ever attached."
/>
{move || match account.get() {
None => view! { <Loading/> }.into_any(),
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
Some(Ok(a)) => view! { <Configure account=a/> }.into_any(),
}}
}
}
#[component]
fn Configure(account: String) -> impl IntoView {
let acct = StoredValue::new(account);
let shown = LocalResource::new(move || {
let url = format!(
"/api/account/sso?account={}",
crate::auth::percent_encode(&acct.get_value())
);
async move { crate::api::get::<Shown>(&url).await }
});
let failed = RwSignal::new(None::<crate::api::Fail>);
let saved = RwSignal::new(String::new());
let protocol = RwSignal::new("oidc".to_owned());
let display = RwSignal::new(String::new());
let domains = RwSignal::new(String::new());
let issuer = RwSignal::new(String::new());
let client_id = RwSignal::new(String::new());
let secret = RwSignal::new(String::new());
let clear_secret = RwSignal::new(false);
let confirm_remove = RwSignal::new(false);
let claim = RwSignal::new(String::new());
let ca_pem = RwSignal::new(String::new());
let clear_ca = RwSignal::new(false);
let md_url = RwSignal::new(String::new());
let md_xml = RwSignal::new(String::new());
let attribute = RwSignal::new(String::new());
let require_sso = RwSignal::new(false);
let require_passkey = RwSignal::new(false);
let jit = RwSignal::new("open".to_owned());
let token_label = RwSignal::new(String::new());
let token_days = RwSignal::new(String::new());
let token_read_only = RwSignal::new(false);
let token_secret = RwSignal::new(String::new());
let csv = RwSignal::new(String::new());
let imported = RwSignal::new(None::<ImportJob>);
let busy = RwSignal::new(false);
let loaded = RwSignal::new(false);
// The form starts from what the server holds, and Save waits for it (RA5-47): a save over
// a slow or failed load would send the form's starting values as the account's policy.
Effect::new(move |_| {
if let Some(Ok(s)) = shown.get() {
if loaded.get_untracked() {
return;
}
loaded.set(true);
if let Some(p) = s.provider {
protocol.set(p.protocol.clone());
display.set(p.display_name.clone());
domains.set(p.domains.join(", "));
issuer.set(p.oidc.issuer.clone());
client_id.set(p.oidc.client_id.clone());
claim.set(p.oidc.username_claim.clone());
md_url.set(p.saml.metadata_url.clone());
attribute.set(p.saml.username_attribute.clone());
require_sso.set(p.require_sso);
require_passkey.set(p.require_passkey);
jit.set(if p.jit.is_empty() { "open".to_owned() } else { p.jit.clone() });
}
}
});
let save = move |_| {
let body = serde_json::json!({
"account": acct.get_value(),
"protocol": protocol.get_untracked(),
"display_name": display.get_untracked().trim(),
"domains": domains.get_untracked().split([',', ' ']).map(str::trim).filter(|d| !d.is_empty()).collect::<Vec<_>>(),
"require_sso": require_sso.get_untracked(),
"require_passkey": require_passkey.get_untracked(),
"jit": jit.get_untracked(),
"issuer": issuer.get_untracked().trim(),
"client_id": client_id.get_untracked().trim(),
"client_secret": secret.get_untracked(),
"clear_secret": clear_secret.get_untracked(),
"username_claim": claim.get_untracked().trim(),
"ca_pem": ca_pem.get_untracked(),
"clear_ca": clear_ca.get_untracked(),
"metadata_url": md_url.get_untracked().trim(),
"metadata_xml": md_xml.get_untracked(),
"username_attribute": attribute.get_untracked().trim(),
});
busy.set(true);
leptos::task::spawn_local(async move {
match crate::api::put::<_, serde_json::Value>("/api/account/sso", &body).await {
Ok(_) => {
failed.set(None);
secret.set(String::new());
clear_secret.set(false);
ca_pem.set(String::new());
clear_ca.set(false);
saved.set("Saved. Your people can now sign in with your company.".to_owned());
shown.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
// A34-49: `DELETE /api/account/sso` — the first press asks, the second removes.
let remove = move |_| {
if !confirm_remove.get_untracked() {
confirm_remove.set(true);
return;
}
busy.set(true);
let url = format!(
"/api/account/sso?account={}",
crate::auth::percent_encode(&acct.get_value())
);
leptos::task::spawn_local(async move {
match crate::api::delete::<serde_json::Value>(&url).await {
Ok(_) => {
failed.set(None);
confirm_remove.set(false);
saved.set("Removed. Nobody signs in through your identity provider any more; your directory is kept.".to_owned());
shown.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
busy.set(false);
});
};
let new_token = move |_| {
let body = serde_json::json!({
"account": acct.get_value(),
"label": token_label.get_untracked().trim(),
"expires_in_days": token_days.get_untracked().trim().parse::<u32>().unwrap_or(0),
"read_only": token_read_only.get_untracked(),
});
leptos::task::spawn_local(async move {
match crate::api::post::<_, Created>("/api/account/sso/scim-tokens", &body).await {
Ok(made) => {
failed.set(None);
token_secret.set(made.secret);
token_label.set(String::new());
shown.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
let import = move |dry_run: bool| {
let body = serde_json::json!({ "account": acct.get_value(), "csv": csv.get_untracked(), "dry_run": dry_run });
leptos::task::spawn_local(async move {
// A34-17: Gunnar queues the import as a job; the page follows it
// and shows Gunnar's words.
let mut job = match crate::api::post::<_, ImportJob>("/api/account/sso/import", &body).await {
Ok(job) => job,
Err(fail) => {
failed.set(Some(fail));
return;
}
};
failed.set(None);
imported.set(Some(job.clone()));
let url = format!(
"/api/account/sso/import?account={}&job_id={}",
crate::auth::percent_encode(&acct.get_value()),
crate::auth::percent_encode(&job.job_id)
);
while !ended(&job.state) {
gloo_timers::future::TimeoutFuture::new(500).await;
job = match crate::api::get::<ImportJob>(&url).await {
Ok(job) => job,
Err(fail) => {
failed.set(Some(fail));
return;
}
};
imported.set(Some(job.clone()));
}
shown.refetch();
});
};
let set_active = move |principal: String, active: bool| {
let body = serde_json::json!({ "account": acct.get_value(), "active": active });
let url = format!(
"/api/account/sso/users/{}/active",
crate::api::segment(&principal)
);
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>(&url, &body).await {
Ok(_) => {
failed.set(None);
shown.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
// A34-5: ask gunnar to read a domain's TXT proof.
let prove = move |domain: String| {
let body = serde_json::json!({ "account": acct.get_value() });
let url = format!("/api/account/sso/domains/{}/verify", crate::api::segment(&domain));
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>(&url, &body).await {
Ok(_) => {
failed.set(None);
shown.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
// A34-11: open a rebind for one person, or everyone bound (an IdP migration).
let rebind = move |principals: Vec<String>| {
let body = serde_json::json!({ "account": acct.get_value(), "principal_ids": principals });
leptos::task::spawn_local(async move {
match crate::api::post::<_, serde_json::Value>("/api/account/sso/rebind", &body).await {
Ok(_) => {
failed.set(None);
shown.refetch();
}
Err(fail) => failed.set(Some(fail)),
}
});
};
let revoke = move |id: String| {
let url = format!(
"/api/account/sso/scim-tokens/{}?account={}",
crate::api::segment(&id),
crate::auth::percent_encode(&acct.get_value())
);
leptos::task::spawn_local(async move {
match crate::api::delete::<serde_json::Value>(&url).await {
Ok(_) => shown.refetch(),
Err(fail) => failed.set(Some(fail)),
}
});
};
move || match shown.get() {
None => view! { <Loading/> }.into_any(),
Some(Err(fail)) => view! { <FailBox fail=fail/> }.into_any(),
Some(Ok(s)) => {
let has_secret = s.provider.as_ref().is_some_and(|p| p.has_client_secret);
let ca_fingerprints = s
.provider
.as_ref()
.map(|p| p.oidc.ca_fingerprints.join(", "))
.unwrap_or_default();
let has_ca = !ca_fingerprints.is_empty();
let fingerprints = s
.provider
.as_ref()
.map(|p| p.saml.certificate_fingerprints.join(", "))
.unwrap_or_default();
let sp_metadata = s
.provider
.as_ref()
.map(|p| p.sp_metadata_xml.clone())
.unwrap_or_default();
let configured = s.provider.is_some();
let proofs = s.provider.as_ref().map(|p| p.domain_proofs.clone()).unwrap_or_default();
view! {
<div class="stack" id="identity-provider" data-account=s.account.clone() data-configured=configured.to_string()>
<Panel title="Your identity provider">
<div class="form-row">
<label class="field">"Protocol"
<select id="sso-protocol" prop:value=move || protocol.get()
on:change=move |ev| protocol.set(event_target_value(&ev))>
<option value="oidc">"OIDC (Entra ID, Okta, Google, Keycloak …)"</option>
<option value="saml">"SAML 2.0"</option>
</select>
</label>
<label class="field">"Shown as"
<input id="sso-display" placeholder="Acme" prop:value=move || display.get()
on:input=move |ev| display.set(event_target_value(&ev))/>
</label>
<label class="field">"Email domains"
<input id="sso-domains" placeholder="acme.com" prop:value=move || domains.get()
on:input=move |ev| domains.set(event_target_value(&ev))/>
</label>
</div>
{move || (protocol.get() == "oidc").then(|| view! {
<div class="form-row" id="sso-oidc">
<label class="field">"Issuer"
<input id="sso-issuer" placeholder="https://login.example.com/…" prop:value=move || issuer.get()
on:input=move |ev| issuer.set(event_target_value(&ev))/>
</label>
<label class="field">"Client id"
<input id="sso-client-id" prop:value=move || client_id.get()
on:input=move |ev| client_id.set(event_target_value(&ev))/>
</label>
<label class="field">"Client secret"
<input id="sso-client-secret" type="password" autocomplete="off"
placeholder=if has_secret { "stored — type to replace" } else { "" }
prop:value=move || secret.get()
on:input=move |ev| secret.set(event_target_value(&ev))/>
</label>
{has_secret.then(|| view! {
<label><input type="checkbox" id="sso-clear-secret" prop:checked=move || clear_secret.get()
on:change=move |ev| clear_secret.set(event_target_checked(&ev))/>
" Remove the stored client secret"</label>
})}
<label class="field">"User-name claim"
<input id="sso-claim" placeholder="preferred_username" prop:value=move || claim.get()
on:input=move |ev| claim.set(event_target_value(&ev))/>
</label>
<CaField ca_pem=ca_pem clear_ca=clear_ca has_ca=has_ca/>
</div>
})}
{move || (protocol.get() == "saml").then(|| view! {
<div class="form-row" id="sso-saml">
<label class="field">"IdP metadata URL"
<input id="sso-metadata-url" placeholder="https://…/metadata" prop:value=move || md_url.get()
on:input=move |ev| md_url.set(event_target_value(&ev))/>
</label>
<label class="field">"…or paste the metadata"
<textarea id="sso-metadata-xml" rows="3" prop:value=move || md_xml.get()
on:input=move |ev| md_xml.set(event_target_value(&ev))></textarea>
</label>
<label class="field">"User-name attribute (empty: NameID)"
<input id="sso-attribute" placeholder="email" prop:value=move || attribute.get()
on:input=move |ev| attribute.set(event_target_value(&ev))/>
</label>
<CaField ca_pem=ca_pem clear_ca=clear_ca has_ca=has_ca/>
</div>
})}
{(!proofs.is_empty()).then(|| view! {
<div id="sso-domain-proofs">
<p class="hint">"A domain leads your people here only after you prove it: publish this TXT record in its DNS, then Verify."</p>
<ul>
{proofs.iter().map(|d| {
let domain = d.domain.clone();
view! { <li data-domain=d.domain.clone() data-verified=d.verified.to_string()>
<span class="mono">{d.domain.clone()}</span>" "
{if d.verified {
view! { <span class="hint">"proven"</span> }.into_any()
} else {
view! { <span><span class="mono">{format!("TXT {} \"{}\"", d.txt_name, d.txt_value)}</span>" "
<button class="btn" on:click=move |_| prove(domain.clone())>"Verify"</button></span> }.into_any()
}}
</li> }
}).collect_view()}
</ul>
</div>
})}
<div class="form-row">
<label class="field">"New people from the identity provider"
<select id="sso-jit" prop:value=move || jit.get()
on:change=move |ev| jit.set(event_target_value(&ev))>
<option value="open">"Anyone it signs in becomes a user"</option>
<option value="domains">"Only a verified e-mail in a proven domain"</option>
<option value="off">"Nobody: SCIM or the CSV import adds people"</option>
</select>
</label>
</div>
<p>
<label><input type="checkbox" id="sso-require-sso" prop:checked=move || require_sso.get()
on:change=move |ev| require_sso.set(event_target_checked(&ev))/>
" Require single sign-on for your employees (you keep your passkey)"</label>
</p>
<p>
<label><input type="checkbox" id="sso-require-passkey" prop:checked=move || require_passkey.get()
on:change=move |ev| require_passkey.set(event_target_checked(&ev))/>
" Also require a passkey after the identity provider"</label>
</p>
<div id="sso-first-passkey">
<Hint
line="An employee's first passkey is trusted on their identity provider's sign-in."
more="An employee with no passkey yet registers their first passkey right after their first sign-in through your identity provider. That first passkey is trusted on that sign-in alone, so your identity provider vouches for it."
/>
</div>
<p>
<button class="btn primary" id="sso-save" on:click=save disabled=move || busy.get() || !loaded.get()>"Save"</button>
{configured.then(|| view! {
" "
<button class="btn" id="sso-remove" on:click=remove disabled=move || busy.get()>
{move || if confirm_remove.get() { "Yes, remove the provider" } else { "Remove provider" }}
</button>
})}
</p>
{move || confirm_remove.get().then(|| view! {
<p class="hint" id="sso-remove-ask">"Press again to remove it. Your directory is kept."</p>
})}
{move || (!saved.get().is_empty()).then(|| view! { <p class="hint" id="sso-saved">{saved.get()}</p> })}
<dl class="kv">
<dt>"Sign-in link for your people"</dt><dd class="mono" id="sso-login-url">{s.login_url.clone()}</dd>
<dt>"OIDC redirect URI"</dt><dd class="mono" id="sso-redirect-uri">{s.redirect_uri.clone()}</dd>
<dt>"SAML entity id"</dt><dd class="mono">{s.sp_entity_id.clone()}</dd>
<dt>"SAML ACS URL"</dt><dd class="mono">{s.acs_url.clone()}</dd>
{(!fingerprints.is_empty()).then(|| view! { <dt>"IdP certificate"</dt><dd class="mono">{fingerprints.clone()}</dd> })}
{has_ca.then(|| view! { <dt>"IdP CA"</dt><dd class="mono" id="sso-ca-fingerprint">{ca_fingerprints.clone()}</dd> })}
</dl>
{(!sp_metadata.is_empty()).then(|| view! {
<details><summary>"SP metadata to upload to your IdP"</summary><pre class="mono">{sp_metadata.clone()}</pre></details>
})}
</Panel>
<Panel title="Provisioning (SCIM 2.0)">
<p class="hint">"SCIM base URL: " <span class="mono" id="scim-base-url">{s.scim_base_url.clone()}</span></p>
<ul id="scim-tokens">
{s.scim_tokens.iter().map(|t| {
let id = t.token_id.clone();
let notes = format!(
"{}{}",
if t.read_only { " (read only)" } else { "" },
if t.attention.is_empty() { String::new() } else { format!(" — {}", t.attention) }
);
view! { <li><span class="mono">{t.token_id.clone()}</span>" " {t.label.clone()} {notes} " "
<button class="btn" on:click=move |_| revoke(id.clone())>"Revoke"</button></li> }
}).collect_view()}
</ul>
<div class="form-row">
<label class="field">"Label"<input id="scim-label" placeholder="Entra provisioning" prop:value=move || token_label.get()
on:input=move |ev| token_label.set(event_target_value(&ev))/></label>
<label class="field">"Expires after (days, empty: never)"<input id="scim-days" inputmode="numeric" prop:value=move || token_days.get()
on:input=move |ev| token_days.set(event_target_value(&ev))/></label>
<label><input type="checkbox" id="scim-read-only" prop:checked=move || token_read_only.get()
on:change=move |ev| token_read_only.set(event_target_checked(&ev))/>" Read only"</label>
</div>
<p><button class="btn" id="scim-new-token" on:click=new_token>"New SCIM token"</button></p>
{move || (!token_secret.get().is_empty()).then(|| view! {
<p class="hint">"Copy it now — it is not shown again: " <span class="mono" id="scim-token-secret">{token_secret.get()}</span></p>
})}
</Panel>
<Panel title="Import users (CSV)">
<p class="hint">"Columns: user_name, display_name, email, active. New names become new users; active=false deactivates."</p>
<textarea id="csv-text" rows="5" class="mono" prop:value=move || csv.get()
on:input=move |ev| csv.set(event_target_value(&ev))></textarea>
<p>
<button class="btn" id="csv-dry-run" on:click=move |_| import(true)>"Check"</button>" "
<button class="btn primary" id="csv-import" on:click=move |_| import(false)>"Import"</button>
</p>
{move || imported.get().map(|d| view! {
<p class="hint" id="csv-result" data-state=d.state.clone()>{import_line(&d)}</p>
})}
</Panel>
<Panel title="Directory">
<p class="hint">{format!("{} people, {} groups", s.users.len(), s.groups)}</p>
<p><button class="btn" id="sso-rebind-all" on:click=move |_| rebind(Vec::new())
title="After moving to a new identity provider: each person's next sign-in binds them afresh">"Rebind everyone (new identity provider)"</button></p>
<div class="table-wrap"><table id="sso-directory">
<thead><tr><th scope="col">"User"</th><th scope="col">"Name"</th><th scope="col">"From"</th><th scope="col">"Signed in"</th><th scope="col">"Active"</th></tr></thead>
<tbody>
{s.users.iter().map(|u| {
let id = u.principal_id.clone();
let again = u.principal_id.clone();
let active = u.active;
let bound = u.bound;
let open = u.rebind;
view! {
<tr data-user=u.principal_id.clone() data-active=active.to_string()>
<td class="mono">{u.principal_id.clone()}" " <span class="hint">{u.user_name.clone()}</span></td>
<td>{u.display_name.clone()}</td>
<td>{u.source.clone()}</td>
<td>{if open { "rebind open" } else if u.bound { "yes" } else { "not yet" }}</td>
<td><button class="btn" on:click=move |_| set_active(id.clone(), !active)>
{if active { "Deactivate" } else { "Reactivate" }}</button>
{bound.then(|| view! { " " <button class="btn" on:click=move |_| rebind(vec![again.clone()])>"Rebind"</button> })}</td>
</tr>
}
}).collect_view()}
</tbody>
</table></div>
</Panel>
{move || failed.get().map(|fail| view! { <FailBox fail=fail/> })}
</div>
}
.into_any()
}
}
}
/// The account's IdP CA (PEM) and its remove box, for either protocol: an
/// OIDC provider's calls and a SAML provider's metadata URL are read trusting
/// it (A34-41). Gunnar judges it; the page shows its fingerprints.
#[component]
fn CaField(ca_pem: RwSignal<String>, clear_ca: RwSignal<bool>, has_ca: bool) -> impl IntoView {
view! {
<label class="field">"IdP CA certificate (PEM; only if your IdP is behind your own CA)"
<textarea id="sso-ca-pem" rows="3" class="mono"
placeholder=if has_ca { "stored — paste to replace" } else { "-----BEGIN CERTIFICATE-----" }
prop:value=move || ca_pem.get()
on:input=move |ev| ca_pem.set(event_target_value(&ev))></textarea>
</label>
{has_ca.then(|| view! {
<label><input type="checkbox" id="sso-clear-ca" prop:checked=move || clear_ca.get()
on:change=move |ev| clear_ca.set(event_target_checked(&ev))/>
" Remove the stored IdP CA"</label>
})}
}
}