Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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>
        })}
    }
}