//! Page-level components and their public render entry points.
//!
//! Each `render_xxx` function constructs a Leptos view, drives it through
//! the SSR renderer, and returns a complete HTML document. The doctype is
//! prepended manually because `view!{}` only renders the tree it is given.

use crate::layout::Shell;
use chrono::{DateTime, Utc};
use leptos::prelude::*;
use leptos::reactive::owner::Owner;
use sui_id_shared::api::{AuditLogEntryDto, ClientSummary, UserSummary};

const DOCTYPE: &str = "<!DOCTYPE html>";

/// Severity of a flash banner displayed at the top of a page.
#[derive(Debug, Clone, Copy)]
pub enum FlashKind {
    Info,
    Warn,
    Error,
}

impl FlashKind {
    fn class(self) -> &'static str {
        match self {
            Self::Info => "flash info",
            Self::Warn => "flash warn",
            Self::Error => "flash error",
        }
    }
}

#[derive(Debug, Clone)]
pub struct Flash {
    pub kind: FlashKind,
    pub text: String,
}

fn flash_banner(flash: Option<Flash>) -> Option<impl IntoView> {
    flash.map(|f| view! { <div class=f.kind.class() role="status">{f.text}</div> })
}

fn fmt_time(t: DateTime<Utc>) -> String {
    t.format("%Y-%m-%d %H:%M UTC").to_string()
}

/// Run a closure inside a fresh reactive Owner and prepend the HTML doctype.
fn render<F, V>(f: F) -> String
where
    F: FnOnce() -> V,
    V: IntoView + 'static,
{
    let owner = Owner::new();
    let body = owner.with(|| f().into_view().to_html());
    let mut out = String::with_capacity(DOCTYPE.len() + body.len());
    out.push_str(DOCTYPE);
    out.push_str(&body);
    out
}

// ---------- setup wizard (3 steps: welcome → admin → done) ----------
//
// The design memo describes 4 screens (1 welcome, 2 admin, 3 encryption, 4 done).
// sui-id resolves the master key before HTTP is up (env var or key file with
// auto-generation), so screen 3 has no operator-facing surface to expose; we
// render screens 1 / 2 / 4 as steps 1 / 2 / 3 of a 3-step wizard, preserving
// the design book's screen numbering. See `docs/operators.md` and the v0.20.4
// CHANGELOG entry for the rationale.

/// Numeric position of the active step. 0-indexed for array math, but
/// the visible label uses `{step + 1} / 3` to match natural language.
fn setup_step_indicator(active: usize, lang: sui_id_i18n::Locale) -> impl IntoView {
    // Five labelled dots showing which step the operator is on.
    // Steps are: Welcome(0), Admin(1), Language(2), HIBP(3), Done(4).
    let t = lang.strings();
    let labels = [
        t.setup_step_welcome,
        t.setup_step_admin,
        t.setup_step_lang,
        t.setup_step_hibp,
        t.setup_step_done,
    ];
    let dots: Vec<_> = labels
        .iter()
        .enumerate()
        .map(|(i, label)| {
            let is_active = i == active;
            let aria = if is_active { Some("step") } else { None };
            let badge = if i < active {
                view! { <span class="badge badge--ok">{format!("{}", i + 1)}</span> }
                    .into_any()
            } else if is_active {
                view! { <span class="badge badge--accent">{format!("{}", i + 1)}</span> }
                    .into_any()
            } else {
                view! { <span class="badge">{format!("{}", i + 1)}</span> }.into_any()
            };
            let style = if is_active {
                "color:var(--fg-default);font-weight:var(--font-weight-medium)"
            } else if i < active {
                "color:var(--fg-muted)"
            } else {
                "color:var(--fg-subtle)"
            };
            view! {
                <span class="row" style="gap:var(--space-1);align-items:center" aria-current=aria>
                    {badge}
                    <span style=style>{*label}</span>
                </span>
            }
        })
        .collect();
    view! {
        <nav class="row"
             aria-label="Setup steps"
             style="gap:var(--space-3);justify-content:center;margin-bottom:var(--space-4);flex-wrap:wrap;font-size:var(--font-size-caption)">
            {dots}
        </nav>
    }
}

/// Step 1 of 3 — welcome.
pub fn render_setup_welcome(flash: Option<Flash>, lang: sui_id_i18n::Locale) -> String {
    render(move || {
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.setup_welcome_title.to_string() lang=lang>
                {setup_step_indicator(0, lang)}
                <h1>{t.setup_welcome_title}</h1>
                <p class="muted">{t.setup_welcome_lede}</p>
                <p class="muted">{t.setup_welcome_lede2}</p>
                {flash_banner(flash)}
                <p style="margin-top:var(--space-4)">
                    <a href="/setup/admin" class="button">{t.setup_welcome_begin}</a>
                </p>
            </crate::layout::AuthShell>
        }
    })
}

/// Step 2 of 3 — admin form.
pub fn render_setup_admin(flash: Option<Flash>, lang: sui_id_i18n::Locale) -> String {
    render(move || {
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.setup_admin_title.to_string() lang=lang>
                {setup_step_indicator(1, lang)}
                <h1>{t.setup_admin_title}</h1>
                <p class="muted">{t.setup_admin_lede}</p>
                {flash_banner(flash)}
                <form method="post" action="/setup/admin" class="stack" autocomplete="off">
                    <div class="field">
                        <label for="token" class="field__label">{t.setup_admin_token_label}</label>
                        <input id="token" name="setup_token" type="password"
                               required=true autocomplete="off" autofocus=true />
                        <span class="field__hint">{t.setup_admin_token_hint}</span>
                    </div>
                    <div class="field">
                        <label for="username" class="field__label">{t.setup_admin_username_label}</label>
                        <input id="username" name="username" type="text"
                               required=true autocomplete="username" />
                    </div>
                    <div class="field">
                        <label for="email" class="field__label">{t.setup_admin_email_label}</label>
                        <input id="email" name="email" type="email" autocomplete="email" />
                        <span class="field__hint">{t.setup_admin_email_hint}</span>
                    </div>
                    <div class="field">
                        <label for="display" class="field__label">{t.setup_admin_display_label}</label>
                        <input id="display" name="display_name" type="text" autocomplete="name" />
                    </div>
                    <div class="field">
                        <label for="password" class="field__label">{t.setup_admin_password_label}</label>
                        <input id="password" name="password" type="password"
                               required=true minlength="12" autocomplete="new-password" />
                        <span class="field__hint">{t.setup_admin_password_hint}</span>
                    </div>
                    <div class="field">
                        <label for="confirm_password" class="field__label">{t.setup_admin_confirm_label}</label>
                        <input id="confirm_password" name="confirm_password" type="password"
                               required=true minlength="12" autocomplete="new-password" />
                    </div>
                    <div class="row">
                        <a href="/setup" class="button secondary">{t.button_back}</a>
                        <button type="submit">{t.setup_admin_submit}</button>
                    </div>
                </form>
            </crate::layout::AuthShell>
        }
    })
}

/// Step 3 of 5 — language selection (RFC 012).
pub fn render_setup_lang(flash: Option<Flash>, current: &str, lang: sui_id_i18n::Locale) -> String {
    let current = current.to_owned();
    render(move || {
        let t = lang.strings();
        let ja_checked = current.is_empty() || current == "ja";
        let en_checked = current == "en";
        view! {
            <crate::layout::AuthShell title=t.setup_lang_title.to_string() lang=lang>
                {setup_step_indicator(2, lang)}
                <h1>{t.setup_lang_title}</h1>
                <p class="muted">{t.setup_lang_lede}</p>
                {flash_banner(flash)}
                <form method="post" action="/setup/lang" class="stack">
                    <fieldset style="border:none;padding:0;margin:0">
                        <legend class="field__label">{t.setup_lang_field_label}</legend>
                        <div class="stack" style="gap:var(--space-2)">
                            <label class="row" style="gap:var(--space-2);align-items:center;cursor:pointer">
                                <input type="radio" name="lang" value="ja"
                                       checked=ja_checked />
                                <span>"日本語"</span>
                            </label>
                            <label class="row" style="gap:var(--space-2);align-items:center;cursor:pointer">
                                <input type="radio" name="lang" value="en"
                                       checked=en_checked />
                                <span>"English"</span>
                            </label>
                        </div>
                    </fieldset>
                    <p class="muted" style="font-size:var(--font-size-caption)">{t.setup_lang_default_note}</p>
                    <div class="row" style="justify-content:flex-end">
                        <button type="submit">{t.setup_lang_submit}</button>
                    </div>
                </form>
            </crate::layout::AuthShell>
        }
    })
}

/// Step 4 of 5 — HIBP policy selection (RFC 012).
pub fn render_setup_hibp(flash: Option<Flash>, current: &str, lang: sui_id_i18n::Locale) -> String {
    let current = current.to_owned();
    render(move || {
        let t = lang.strings();
        let off_checked = current == "off";
        let warn_checked = current.is_empty() || current == "warn";
        let block_checked = current == "block";
        view! {
            <crate::layout::AuthShell title=t.setup_hibp_step_title.to_string() lang=lang>
                {setup_step_indicator(3, lang)}
                <h1>{t.setup_hibp_step_title}</h1>
                <p class="muted">{t.setup_hibp_step_lede}</p>
                {flash_banner(flash)}
                <form method="post" action="/setup/hibp" class="stack">
                    <fieldset style="border:none;padding:0;margin:0">
                        <div class="stack" style="gap:var(--space-3)">
                            <label class="card" style="cursor:pointer;display:block">
                                <div class="row" style="gap:var(--space-2);align-items:center">
                                    <input type="radio" name="hibp_mode" value="off"
                                           checked=off_checked />
                                    <strong>{t.setup_hibp_option_off}</strong>
                                </div>
                                <p class="muted" style="margin:var(--space-1) 0 0 calc(1em + var(--space-2));font-size:var(--font-size-caption)">{t.setup_hibp_option_off_desc}</p>
                            </label>
                            <label class="card" style="cursor:pointer;display:block">
                                <div class="row" style="gap:var(--space-2);align-items:center">
                                    <input type="radio" name="hibp_mode" value="warn"
                                           checked=warn_checked />
                                    <strong>{t.setup_hibp_option_warn}</strong>
                                </div>
                                <p class="muted" style="margin:var(--space-1) 0 0 calc(1em + var(--space-2));font-size:var(--font-size-caption)">{t.setup_hibp_option_warn_desc}</p>
                            </label>
                            <label class="card" style="cursor:pointer;display:block">
                                <div class="row" style="gap:var(--space-2);align-items:center">
                                    <input type="radio" name="hibp_mode" value="block"
                                           checked=block_checked />
                                    <strong>{t.setup_hibp_option_block}</strong>
                                </div>
                                <p class="muted" style="margin:var(--space-1) 0 0 calc(1em + var(--space-2));font-size:var(--font-size-caption)">{t.setup_hibp_option_block_desc}</p>
                            </label>
                        </div>
                    </fieldset>
                    <p class="muted" style="font-size:var(--font-size-caption)">{t.setup_hibp_step_default_note}</p>
                    <div class="row" style="justify-content:flex-end">
                        <button type="submit">{t.setup_hibp_step_submit}</button>
                    </div>
                </form>
            </crate::layout::AuthShell>
        }
    })
}

/// Step 5 of 5 — completion.
pub fn render_setup_done(initialized: bool, lang: sui_id_i18n::Locale) -> String {
    render(move || {
        let t = lang.strings();
        if initialized {
            view! {
                <crate::layout::AuthShell title=t.setup_done_title.to_string() lang=lang>
                    {setup_step_indicator(4, lang)}
                    <h1>{t.setup_done_title}</h1>
                    <p class="muted">{t.setup_done_lede}</p>
                    <div class="card">
                        <h3 class="card__title">{t.setup_done_next_steps_title}</h3>
                        <ul class="muted" style="margin:0;padding-left:var(--space-4)">
                            <li>{t.setup_done_next_step_register_clients}</li>
                            <li>{t.setup_done_next_step_enable_mfa}</li>
                            <li>{t.setup_done_next_step_review_settings}</li>
                        </ul>
                    </div>
                    <p style="margin-top:var(--space-4)">
                        <a href="/admin" class="button">{t.setup_done_enter_admin}</a>
                    </p>
                </crate::layout::AuthShell>
            }
            .into_any()
        } else {
            view! {
                <crate::layout::AuthShell title=t.setup_not_complete_title.to_string() lang=lang>
                    {setup_step_indicator(0, lang)}
                    <h1>{t.setup_not_complete_title}</h1>
                    <p class="muted">{t.setup_not_complete_lede}</p>
                    <p style="margin-top:var(--space-4)">
                        <a href="/setup" class="button">{t.setup_welcome_begin}</a>
                    </p>
                </crate::layout::AuthShell>
            }
            .into_any()
        }
    })
}

// ---------- login ----------

pub fn render_login(
    flash: Option<Flash>,
    next: Option<String>,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let next_value = next.clone().unwrap_or_default();
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.login_title.to_string() lang=lang>
                <h1>{t.login_title}</h1>
                {flash_banner(flash)}
                <form method="post" action="/admin/login" class="stack">
                    <input type="hidden" name="next" value=next_value />
                    <div class="field">
                        <label for="username" class="field__label">{t.login_username_label}</label>
                        <input id="username" name="username" type="text"
                               required=true autocomplete="username"
                               autofocus=true />
                    </div>
                    <div class="field">
                        <label for="password" class="field__label">{t.login_password_label}</label>
                        <input id="password" name="password" type="password"
                               required=true autocomplete="current-password" />
                    </div>
                    <button type="submit">{t.login_submit}</button>
                </form>
                <p class="muted" style="margin-top:var(--space-3);text-align:center;font-size:var(--font-size-caption)">
                    <a href="/forgot-password">{t.login_forgot_password_link}</a>
                </p>
            </crate::layout::AuthShell>
        }
    })
}

// ---------- MFA challenge ----------

pub fn render_mfa_challenge(
    flash: Option<Flash>,
    csrf_token: String,
    has_passkey: bool,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        let csrf_for_totp = csrf_token.clone();
        let csrf_for_pk = csrf_token.clone();
        let passkey_block = if has_passkey {
            view! {
                <hr class="divider" />
                <p class="muted">{t.mfa_challenge_passkey_alt}</p>
                <form id="passkey-auth-form" method="post"
                      action="/admin/login/webauthn/start" class="stack">
                    <input type="hidden" name="_csrf" value=csrf_for_pk />
                    <button type="submit" class="secondary">{t.mfa_challenge_passkey_button}</button>
                </form>
                <script src="/static/webauthn.js"></script>
            }
            .into_any()
        } else {
            view! { <></> }.into_any()
        };
        view! {
            <crate::layout::AuthShell title=t.mfa_challenge_shell_title.to_string() lang=lang>
                <h1>{t.mfa_challenge_title}</h1>
                {flash_banner(flash)}
                <p class="muted">{t.mfa_challenge_lede}</p>
                <form method="post" action="/admin/login/mfa" class="stack">
                    <input type="hidden" name="_csrf" value=csrf_for_totp />
                    <div class="field">
                        <label for="code" class="field__label">{t.mfa_challenge_code_label}</label>
                        <input id="code" name="code" type="text"
                               required=true autocomplete="one-time-code"
                               inputmode="text" autofocus=true />
                    </div>
                    <button type="submit">{t.mfa_challenge_submit}</button>
                </form>
                {passkey_block}
            </crate::layout::AuthShell>
        }
    })
}

// ---------- profile (MFA settings) ----------

pub struct ProfileData {
    pub username: String,
    /// True if TOTP is set up.
    pub totp_enabled: bool,
    /// Set when the user has just enrolled or regenerated codes.
    /// Displayed exactly once.
    pub fresh_recovery_codes: Option<Vec<String>>,
    /// Registered WebAuthn passkeys for this user.
    pub passkeys: Vec<PasskeyDescriptor>,
    /// User's currently-stored language preference (BCP-47 tag).
    /// `None` means "follow browser default" (the cookie /
    /// Accept-Language tier of the resolution chain takes over).
    pub preferred_lang: Option<String>,
}

pub struct PasskeyDescriptor {
    pub id: String,
    pub nickname: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
}

pub fn render_profile(
    data: ProfileData,
    flash: Option<Flash>,
    csrf_token: String,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        let ProfileData {
            username,
            totp_enabled,
            fresh_recovery_codes,
            passkeys,
            preferred_lang,
        } = data;
        let csrf_for_disable = csrf_token.clone();
        let csrf_for_regen = csrf_token.clone();
        let csrf_for_enroll = csrf_token.clone();
        let csrf_for_passkey_register = csrf_token.clone();
        let csrf_for_passkey_delete = csrf_token.clone();
        let csrf_for_lang = csrf_token.clone();
        let preferred_lang = preferred_lang;
        let recovery_block = fresh_recovery_codes.map(|codes| {
            let lis: Vec<_> = codes
                .into_iter()
                .map(|c| view! { <li><span class="code">{c}</span></li> })
                .collect();
            view! {
                <div class="flash warn" role="status">
                    <div class="stack-tight">
                        <strong>{t.profile_recovery_save_now}</strong>
                        <p class="muted">{t.profile_recovery_save_lede}</p>
                        <ol>{lis}</ol>
                    </div>
                </div>
            }
        });
        let mfa_disable_onsubmit = format!(
            "return confirm('{}');",
            t.profile_mfa_disable_confirm.replace('\'', "\\'")
        );
        let mfa_section = if totp_enabled {
            view! {
                <div class="card">
                    <h3 class="card__title">{t.profile_mfa_totp_card_title}</h3>
                    <p>
                        {t.profile_mfa_status_label}
                        <span class="badge badge--ok" style="margin-left:var(--space-1)">{t.profile_mfa_status_enabled}</span>
                    </p>
                    <div class="card__footer">
                        <form method="post" action="/admin/profile/mfa/recovery-codes/regenerate"
                              style="display:inline">
                            <input type="hidden" name="_csrf" value=csrf_for_regen />
                            <button type="submit" class="secondary">{t.profile_mfa_regenerate_codes}</button>
                        </form>
                        <form method="post" action="/admin/profile/mfa/disable"
                              style="display:inline"
                              onsubmit=mfa_disable_onsubmit>
                            <input type="hidden" name="_csrf" value=csrf_for_disable />
                            <button type="submit" class="danger">{t.profile_mfa_disable_button}</button>
                        </form>
                    </div>
                </div>
            }
            .into_any()
        } else {
            view! {
                <div class="card">
                    <h3 class="card__title">{t.profile_mfa_totp_card_title}</h3>
                    <p>
                        {t.profile_mfa_status_label}
                        <span class="badge badge--warn" style="margin-left:var(--space-1)">{t.profile_mfa_status_not_configured}</span>
                    </p>
                    <p class="muted">
                        {t.profile_mfa_enroll_lede}
                        " "
                        {t.profile_mfa_enroll_apps_note}
                    </p>
                    <div class="card__footer">
                        <form method="post" action="/admin/profile/mfa/enroll/start">
                            <input type="hidden" name="_csrf" value=csrf_for_enroll />
                            <button type="submit">{t.profile_mfa_enroll_button}</button>
                        </form>
                    </div>
                </div>
            }
            .into_any()
        };
        let passkey_delete_onsubmit = format!(
            "return confirm('{}');",
            t.profile_passkeys_delete_confirm.replace('\'', "\\'")
        );
        let passkey_last_used_never = t.profile_passkeys_last_used_never.to_owned();
        let passkey_delete_label = t.profile_passkeys_delete_button;
        let passkey_rows: Vec<_> = passkeys
            .into_iter()
            .map(|p| {
                let id = p.id.clone();
                let delete_url = format!("/admin/profile/webauthn/{id}/delete");
                let csrf = csrf_for_passkey_delete.clone();
                let last = p
                    .last_used_at
                    .map(fmt_time)
                    .unwrap_or_else(|| passkey_last_used_never.clone());
                let onsubmit = passkey_delete_onsubmit.clone();
                view! {
                    <tr>
                        <td>{p.nickname}</td>
                        <td class="muted">{fmt_time(p.created_at)}</td>
                        <td class="muted">{last}</td>
                        <td>
                            <form method="post" action=delete_url style="display:inline"
                                  onsubmit=onsubmit>
                                <input type="hidden" name="_csrf" value=csrf />
                                <button type="submit" class="danger">{passkey_delete_label}</button>
                            </form>
                        </td>
                    </tr>
                }
            })
            .collect();
        let passkey_table = if passkey_rows.is_empty() {
            view! {
                <p class="muted">{t.profile_passkeys_empty}</p>
            }
            .into_any()
        } else {
            view! {
                <div class="table-wrap">
                    <table>
                        <thead>
                            <tr>
                                <th>{t.profile_passkeys_th_name}</th>
                                <th>{t.profile_passkeys_th_registered}</th>
                                <th>{t.profile_passkeys_th_last_used}</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody>{passkey_rows}</tbody>
                    </table>
                </div>
            }
            .into_any()
        };
        let subtitle = t.profile_subtitle_template.replace("{username}", &username);
        view! {
            <Shell title=t.profile_title.to_string() show_nav=true current=Some("profile".to_string()) lang=lang>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">{t.profile_title}</h1>
                        <p class="page-header__lede">{subtitle}</p>
                    </div>
                </header>
                {flash_banner(flash)}
                {recovery_block}

                <section>
                    <h2>{t.profile_mfa_section}</h2>
                    {mfa_section}
                </section>

                <section>
                    <h2>{t.profile_passkeys_section}</h2>
                    <p class="muted">{t.profile_passkeys_lede}</p>
                    {passkey_table}
                    <h3>{t.profile_passkeys_register_section}</h3>
                    <div class="card">
                        <form id="passkey-register-form" method="post"
                              action="/admin/profile/webauthn/register/start" class="stack">
                            <input type="hidden" name="_csrf" value=csrf_for_passkey_register />
                            <div class="field">
                                <label for="pk-nickname" class="field__label">{t.profile_passkeys_nickname_label}</label>
                                <input id="pk-nickname" name="nickname" type="text" required=true />
                                <span class="field__hint">{t.profile_passkeys_nickname_hint}</span>
                            </div>
                            <div>
                                <button type="submit">{t.profile_passkeys_register_button}</button>
                            </div>
                        </form>
                    </div>
                </section>

                <section class="section">
                    <h2 class="section__title">{t.profile_lang_section}</h2>
                    <p class="muted">{t.profile_lang_lede}</p>
                    <div class="card">
                        <form method="post" action="/admin/profile/lang" class="stack">
                            <input type="hidden" name="_csrf" value=csrf_for_lang />
                            <div class="field">
                                <label for="lang-select" class="field__label">{t.profile_lang_field_label}</label>
                                <select id="lang-select" name="lang">
                                    <option value="" selected=preferred_lang.is_none()>
                                        {t.profile_lang_browser_default}
                                    </option>
                                    {sui_id_i18n::Locale::ALL.iter().map(|loc| {
                                        let tag = loc.tag();
                                        let selected = preferred_lang.as_deref() == Some(tag);
                                        let label = loc.native_name();
                                        view! {
                                            <option value=tag selected=selected>{label}</option>
                                        }
                                    }).collect::<Vec<_>>()}
                                </select>
                            </div>
                            <div>
                                <button type="submit">{t.button_save}</button>
                            </div>
                        </form>
                    </div>
                </section>
                <script src="/static/webauthn.js"></script>
            </Shell>
        }
    })
}

pub struct MfaSetupData {
    /// otpauth:// URI for the QR code
    pub otpauth_uri: String,
    /// Pre-rendered SVG of the QR code (full <svg>...</svg> string).
    pub qr_svg: String,
    /// Base32-encoded secret string for users who would rather type it
    /// in than scan the QR code.
    pub secret_b32: String,
}

pub fn render_mfa_setup(
    data: MfaSetupData,
    flash: Option<Flash>,
    csrf_token: String,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        let MfaSetupData { otpauth_uri, qr_svg, secret_b32 } = data;
        view! {
            <Shell title=t.mfa_setup_shell_title.to_string() show_nav=true current=Some("profile".to_string()) lang=lang>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">{t.mfa_setup_title}</h1>
                        <p class="page-header__lede">{t.mfa_setup_lede}</p>
                    </div>
                </header>
                {flash_banner(flash)}

                <div class="card">
                    <h3 class="card__title">{t.mfa_setup_steps_title}</h3>
                    <ol>
                        <li>{t.mfa_setup_step1}</li>
                        <li>{t.mfa_setup_step2}</li>
                        <li>{t.mfa_setup_step3}</li>
                    </ol>
                </div>

                <div class="card">
                    <h3 class="card__title">{t.mfa_setup_qr_card_title}</h3>
                    <div inner_html=qr_svg style="max-width:240px;margin-bottom:var(--space-3)"></div>
                    <p>{t.mfa_setup_secret_label}<span class="code" style="margin-left:var(--space-1)">{secret_b32}</span></p>
                    <details>
                        <summary class="muted">{t.mfa_setup_otpauth_summary}</summary>
                        <p><span class="code">{otpauth_uri}</span></p>
                    </details>
                </div>

                <div class="card">
                    <h3 class="card__title">{t.mfa_setup_verify_card_title}</h3>
                    <form method="post" action="/admin/profile/mfa/enroll/confirm" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_token />
                        <div class="field">
                            <label for="code" class="field__label">{t.mfa_setup_code_label}</label>
                            <input id="code" name="code" type="text" required=true
                                   autocomplete="one-time-code" inputmode="text" autofocus=true />
                            <span class="field__hint">{t.mfa_setup_code_hint}</span>
                        </div>
                        <div>
                            <button type="submit">{t.mfa_setup_confirm_button}</button>
                        </div>
                    </form>
                </div>
            </Shell>
        }
    })
}

// ---------- dashboard ----------

/// One bucket of the login-activity sparkline as the renderer
/// wants it: pre-formatted display label, plus the two raw counts.
/// The renderer doesn't need to know the bucket spacing or the
/// range — that's the caller's job.
pub struct DashboardSparkBucket {
    /// Human-readable label for hover tooltip ("2026-04-26 14:00").
    pub label: String,
    pub success: i64,
    pub failure: i64,
}

pub struct DashboardSparkline {
    /// Active range, used to highlight the right tab.
    pub active_range_query: String,
    /// (query string, human label) for each available range tab.
    pub range_options: Vec<(String, String)>,
    /// Window-wide totals shown next to the sparkline.
    pub total_success: i64,
    pub total_failure: i64,
    /// Dense bucket array, oldest first. Empty windows still
    /// produce the right number of zero-count buckets so the
    /// sparkline is the same shape as for a busy window.
    pub buckets: Vec<DashboardSparkBucket>,
}

pub struct DashboardData {
    pub admin_username: String,
    pub user_count: usize,
    pub client_count: usize,
    pub issuer: String,
    pub sparkline: DashboardSparkline,
}

/// Render the inline SVG sparkline.
///
/// The SVG is hand-coded rather than pulled from a charting
/// library: we only need a stacked area for two series, the
/// dimensions are fixed, and we avoid both a runtime dependency
/// and any CSP relaxation. Drawing strategy:
///
/// - viewBox is 0..200 horizontal × 0..60 vertical, scaled by CSS
/// - failures stack at the bottom (so a streak shows up as a thick
///   red base regardless of the success count above it)
/// - successes stack on top of failures
/// - each bucket carries an invisible `<rect>` with a `<title>`
///   child so hovering shows the tooltip natively (no JS)
fn render_sparkline(buckets: Vec<DashboardSparkBucket>) -> impl IntoView {
    const WIDTH: f64 = 200.0;
    const HEIGHT: f64 = 60.0;
    const PAD_TOP: f64 = 4.0;
    const PAD_BOTTOM: f64 = 4.0;
    let drawable = HEIGHT - PAD_TOP - PAD_BOTTOM;
    let n = buckets.len().max(1);
    // Largest stacked total across buckets sets the y-scale.
    let max_total = buckets
        .iter()
        .map(|b| b.success + b.failure)
        .max()
        .unwrap_or(0)
        .max(1) as f64;

    let bar_step = WIDTH / n as f64;
    // Each bucket gets a thin gap so adjacent bars are readable.
    let bar_w = (bar_step * 0.78).max(1.0);
    let bar_offset = (bar_step - bar_w) / 2.0;

    let bars: Vec<_> = buckets
        .into_iter()
        .enumerate()
        .map(|(i, b)| {
            let x = bar_step * i as f64 + bar_offset;
            let total = (b.success + b.failure) as f64;
            let total_h = if total > 0.0 {
                (total / max_total) * drawable
            } else {
                0.0
            };
            let success_h = if b.success > 0 {
                (b.success as f64 / max_total) * drawable
            } else {
                0.0
            };
            let failure_h = total_h - success_h;

            let base_y = HEIGHT - PAD_BOTTOM;
            let failure_y = base_y - failure_h;
            let success_y = failure_y - success_h;

            let title = format!(
                "{} : 成功 {} / 失敗 {}",
                b.label, b.success, b.failure
            );

            view! {
                <g>
                    <title>{title}</title>
                    <rect x=format!("{:.2}", bar_step * i as f64)
                          y="0"
                          width=format!("{:.2}", bar_step)
                          height=format!("{HEIGHT}")
                          fill="transparent" />
                    {(failure_h > 0.0).then(|| view! {
                        <rect x=format!("{x:.2}")
                              y=format!("{failure_y:.2}")
                              width=format!("{bar_w:.2}")
                              height=format!("{failure_h:.2}")
                              fill="var(--danger-default)"
                              rx="1" />
                    })}
                    {(success_h > 0.0).then(|| view! {
                        <rect x=format!("{x:.2}")
                              y=format!("{success_y:.2}")
                              width=format!("{bar_w:.2}")
                              height=format!("{success_h:.2}")
                              fill="var(--accent-default)"
                              rx="1" />
                    })}
                </g>
            }
        })
        .collect();

    view! {
        <svg viewBox=format!("0 0 {WIDTH} {HEIGHT}")
             preserveAspectRatio="none"
             role="img"
             aria-label="サインイン活動のスパークライン"
             style="width:100%;height:80px;display:block">
            <line x1="0" y1=format!("{:.2}", HEIGHT - PAD_BOTTOM)
                  x2=format!("{WIDTH}") y2=format!("{:.2}", HEIGHT - PAD_BOTTOM)
                  stroke="var(--border-muted)"
                  stroke-width="1" />
            {bars}
        </svg>
    }
}

pub fn render_dashboard(data: DashboardData, flash: Option<Flash>) -> String {
    render(move || {
        let DashboardData {
            admin_username,
            user_count,
            client_count,
            issuer,
            sparkline,
        } = data;

        let active_range = sparkline.active_range_query.clone();
        let range_tabs: Vec<_> = sparkline
            .range_options
            .iter()
            .map(|(q, label)| {
                let href = format!("/admin?range={q}");
                let aria = if *q == active_range { Some("page") } else { None };
                view! {
                    <a class="app-nav__link" href=href aria-current=aria>{label.clone()}</a>
                }
            })
            .collect();

        let total_success = sparkline.total_success;
        let total_failure = sparkline.total_failure;
        let svg = render_sparkline(sparkline.buckets);

        view! {
            <Shell title="Dashboard".to_string() show_nav=true current=Some("dashboard".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"ダッシュボード"</h1>
                        <p class="page-header__lede">
                            {format!("Hello, {admin_username}. システムの概要と統計情報。")}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}

                <section class="grid-cards" aria-label="統計サマリ">
                    <div class="card">
                        <div class="stat">
                            <span class="stat__value">{user_count.to_string()}</span>
                            <span class="stat__label">"ユーザー"</span>
                        </div>
                    </div>
                    <div class="card">
                        <div class="stat">
                            <span class="stat__value">{client_count.to_string()}</span>
                            <span class="stat__label">"クライアント"</span>
                        </div>
                    </div>
                    <div class="card">
                        <div class="stat">
                            <span class="stat__value">
                                <span class="badge badge--ok">"稼働中"</span>
                            </span>
                            <span class="stat__label">"サービス状態"</span>
                        </div>
                    </div>
                </section>

                <section>
                    <div class="row" style="justify-content:space-between;align-items:flex-end;margin-bottom:var(--space-3)">
                        <h2 style="margin:0">"サインイン活動"</h2>
                        <nav class="app-nav" aria-label="期間" style="flex:0 0 auto">
                            {range_tabs}
                        </nav>
                    </div>
                    <div class="card">
                        <div class="row" style="gap:var(--space-5);margin-bottom:var(--space-3)">
                            <div class="stat">
                                <span class="stat__value" style="color:var(--accent-default)">
                                    {total_success.to_string()}
                                </span>
                                <span class="stat__label">"成功"</span>
                            </div>
                            <div class="stat">
                                <span class="stat__value" style="color:var(--danger-default)">
                                    {total_failure.to_string()}
                                </span>
                                <span class="stat__label">"失敗"</span>
                            </div>
                        </div>
                        {svg}
                        <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                            "ホバーで各バケットの詳細を表示。"
                        </p>
                    </div>
                </section>

                <section>
                    <h2>"OIDC エンドポイント"</h2>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                <tr>
                                    <th scope="row">"Issuer"</th>
                                    <td><span class="code">{issuer}</span></td>
                                </tr>
                                <tr>
                                    <th scope="row">"Discovery"</th>
                                    <td><a href="/.well-known/openid-configuration"><span class="code">"/.well-known/openid-configuration"</span></a></td>
                                </tr>
                                <tr>
                                    <th scope="row">"JWKS"</th>
                                    <td><a href="/.well-known/jwks.json"><span class="code">"/.well-known/jwks.json"</span></a></td>
                                </tr>
                            </tbody>
                        </table>
                    </div>
                </section>
            </Shell>
        }
    })
}

// ---------- users ----------

fn user_row_view(u: UserSummary, current_user: String, csrf: String) -> impl IntoView {
    let display = u.display_name.clone().unwrap_or_default();
    let id_str = u.id.to_string();
    let is_self = u.username == current_user;
    let is_disabled = u.is_disabled;
    let is_deleted = u.is_deleted;
    let is_admin = u.is_admin;
    let mfa_enabled = u.mfa_enabled;
    let action_label = if is_disabled { "Enable" } else { "Disable" };
    let action_target = if is_disabled { "false" } else { "true" };
    let disabled_url = format!("/admin/users/{id_str}/disabled");
    let delete_url = format!("/admin/users/{id_str}/delete");
    let reset_mfa_url = format!("/admin/users/{id_str}/mfa-reset");
    let csrf_disable = csrf.clone();
    let csrf_delete = csrf.clone();
    let csrf_reset = csrf.clone();

    let status_badge = if is_deleted {
        view! { <span class="badge badge--danger">"deleted"</span> }.into_any()
    } else if is_disabled {
        view! { <span class="badge badge--warn">"disabled"</span> }.into_any()
    } else if is_admin {
        view! { <span class="badge badge--accent">"admin"</span> }.into_any()
    } else {
        view! { <span class="badge badge--ok">"active"</span> }.into_any()
    };

    let mfa_cell = if mfa_enabled {
        view! { <td><span class="badge badge--ok">"on"</span></td> }.into_any()
    } else {
        view! { <td><span class="muted">"off"</span></td> }.into_any()
    };

    let actions = if is_self {
        view! { <td><span class="muted">"(you)"</span></td> }.into_any()
    } else if is_deleted {
        view! { <td><span class="muted">"-"</span></td> }.into_any()
    } else {
        let reset_form = if mfa_enabled {
            view! {
                <form method="post" action=reset_mfa_url style="display:inline"
                      onsubmit="return confirm('Forcibly remove every MFA factor for this user (TOTP and all passkeys)? Use only when the user has lost access to their second factor.');">
                    <input type="hidden" name="_csrf" value=csrf_reset />
                    <button type="submit" class="secondary">"Reset MFA"</button>
                </form>
                " "
            }
            .into_any()
        } else {
            view! { <></> }.into_any()
        };
        view! {
            <td>
                <div class="row" style="gap:var(--space-1)">
                    {reset_form}
                    <form method="post" action=disabled_url style="display:inline">
                        <input type="hidden" name="_csrf" value=csrf_disable />
                        <input type="hidden" name="disabled" value=action_target />
                        <button type="submit" class="secondary">{action_label}</button>
                    </form>
                    <form method="post" action=delete_url style="display:inline"
                          onsubmit="return confirm('Permanently delete this user?');">
                        <input type="hidden" name="_csrf" value=csrf_delete />
                        <button type="submit" class="danger">"Delete"</button>
                    </form>
                </div>
            </td>
        }
        .into_any()
    };

    view! {
        <tr>
            <td><span class="code">{u.username}</span></td>
            <td>{display}</td>
            <td>{status_badge}</td>
            {mfa_cell}
            <td class="muted">{fmt_time(u.created_at)}</td>
            {actions}
        </tr>
    }
}

pub fn render_users(
    users: Vec<UserSummary>,
    flash: Option<Flash>,
    current_user: String,
    csrf_token: String,
) -> String {
    render(move || {
        let csrf_for_rows = csrf_token.clone();
        let csrf_for_form = csrf_token.clone();
        let user_count = users.len();
        let rows: Vec<_> = users
            .into_iter()
            .map(|u| user_row_view(u, current_user.clone(), csrf_for_rows.clone()))
            .collect();
        view! {
            <Shell title="Users".to_string() show_nav=true current=Some("users".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"ユーザー管理"</h1>
                        <p class="page-header__lede">
                            "ユーザーの作成・編集・管理。"
                            {format!(" 現在 {user_count} 名。")}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}

                <section>
                    <h2>"新しいユーザーを追加"</h2>
                    <div class="card">
                        <form method="post" action="/admin/users" class="stack">
                            <input type="hidden" name="_csrf" value=csrf_for_form />
                            <div class="field">
                                <label for="u-name" class="field__label">"ユーザー名"</label>
                                <input id="u-name" name="username" type="text"
                                       required=true autocomplete="off" />
                            </div>
                            <div class="field">
                                <label for="u-disp" class="field__label">"表示名(任意)"</label>
                                <input id="u-disp" name="display_name" type="text" autocomplete="off" />
                            </div>
                            <div class="field">
                                <label for="u-email" class="field__label">"メールアドレス(任意)"</label>
                                <input id="u-email" name="email" type="email" autocomplete="off" />
                            </div>
                            <div class="field">
                                <label for="u-pw" class="field__label">"パスワード(12 文字以上)"</label>
                                <input id="u-pw" name="password" type="password"
                                       required=true minlength="12" autocomplete="new-password" />
                            </div>
                            <label class="row" style="gap:var(--space-2)">
                                <input name="is_admin" type="checkbox" value="true" />
                                <span>"管理者権限を付与する"</span>
                            </label>
                            <div>
                                <button type="submit">"ユーザーを作成"</button>
                            </div>
                        </form>
                    </div>
                </section>

                <section>
                    <h2>"ユーザー一覧"</h2>
                    <div class="table-wrap">
                        <table>
                            <thead>
                                <tr>
                                    <th>"ユーザー名"</th>
                                    <th>"表示名"</th>
                                    <th>"状態"</th>
                                    <th>"MFA"</th>
                                    <th>"作成日"</th>
                                    <th></th>
                                </tr>
                            </thead>
                            <tbody>{rows}</tbody>
                        </table>
                    </div>
                </section>
            </Shell>
        }
    })
}

// ---------- clients ----------

fn client_row_view(c: ClientSummary, csrf: String) -> impl IntoView {
    let is_disabled = c.is_disabled;
    let is_deleted = c.is_deleted;
    let kind = if c.confidential { "confidential" } else { "public" };
    let id_str = c.id.to_string();
    let action_label = if is_disabled { "Enable" } else { "Disable" };
    let action_target = if is_disabled { "false" } else { "true" };
    let disabled_url = format!("/admin/clients/{id_str}/disabled");
    let delete_url = format!("/admin/clients/{id_str}/delete");
    let csrf_disable = csrf.clone();
    let csrf_delete = csrf.clone();
    let scopes_display = if c.allowed_scopes.trim().is_empty() {
        "(any)".to_string()
    } else {
        c.allowed_scopes.clone()
    };
    let logout_count = c.post_logout_redirect_uris.len();
    let logout_display = if logout_count == 0 {
        "(falls back to redirect_uris)".to_string()
    } else {
        format!("{logout_count} URI(s)")
    };

    let status_badge = if is_deleted {
        view! { <span class="badge badge--danger">"deleted"</span> }.into_any()
    } else if is_disabled {
        view! { <span class="badge badge--warn">"disabled"</span> }.into_any()
    } else {
        view! { <span class="badge badge--ok">"active"</span> }.into_any()
    };

    let edit_url = format!("/admin/clients/{id_str}/edit");
    let actions = if is_deleted {
        view! { <td><span class="muted">"-"</span></td> }.into_any()
    } else {
        view! {
            <td>
                <div class="row" style="gap:var(--space-1)">
                    <a href=edit_url class="button secondary">"Edit"</a>
                    <form method="post" action=disabled_url style="display:inline">
                        <input type="hidden" name="_csrf" value=csrf_disable />
                        <input type="hidden" name="disabled" value=action_target />
                        <button type="submit" class="secondary">{action_label}</button>
                    </form>
                    <form method="post" action=delete_url style="display:inline"
                          onsubmit="return confirm('Permanently delete this client and revoke its tokens?');">
                        <input type="hidden" name="_csrf" value=csrf_delete />
                        <button type="submit" class="danger">"Delete"</button>
                    </form>
                </div>
            </td>
        }
        .into_any()
    };

    view! {
        <tr>
            <td>{c.name}</td>
            <td><span class="code">{c.id.to_string()}</span></td>
            <td>{kind}</td>
            <td><span class="code">{scopes_display}</span></td>
            <td class="muted">{logout_display}</td>
            <td>{status_badge}</td>
            {actions}
        </tr>
    }
}

pub fn render_clients(
    clients: Vec<ClientSummary>,
    flash: Option<Flash>,
    new_secret: Option<(String, String)>,
    csrf_token: String,
) -> String {
    render(move || {
        let csrf_for_rows = csrf_token.clone();
        let csrf_for_form = csrf_token.clone();
        let client_count = clients.len();
        let secret_block = new_secret.map(|(cid, sec)| {
            view! {
                <div class="flash warn" role="status">
                    <div class="stack-tight">
                        <strong>"クライアント Secret は今だけ表示されます。安全な場所に保存してください。"</strong>
                        <div>"Client ID: "<span class="code">{cid}</span></div>
                        <div>"Client Secret: "<span class="code">{sec}</span></div>
                    </div>
                </div>
            }
        });
        let rows: Vec<_> = clients
            .into_iter()
            .map(|c| client_row_view(c, csrf_for_rows.clone()))
            .collect();
        view! {
            <Shell title="Clients".to_string() show_nav=true current=Some("clients".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"クライアント管理"</h1>
                        <p class="page-header__lede">
                            "OIDC クライアントの登録と管理。"
                            {format!(" 現在 {client_count} 件。")}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {secret_block}

                <section>
                    <h2>"新しいクライアントを登録"</h2>
                    <div class="card">
                        <form method="post" action="/admin/clients" class="stack">
                            <input type="hidden" name="_csrf" value=csrf_for_form />
                            <div class="field">
                                <label for="c-name" class="field__label">"アプリケーション名"</label>
                                <input id="c-name" name="name" type="text" required=true />
                            </div>
                            <div class="field">
                                <label for="c-uris" class="field__label">"Redirect URIs"</label>
                                <textarea id="c-uris" name="redirect_uris" required=true rows="3"></textarea>
                                <span class="field__hint">"1 行に 1 つ。https またはループバックの http のみ。"</span>
                            </div>
                            <div class="field">
                                <label for="c-scopes" class="field__label">"許可スコープ"</label>
                                <input id="c-scopes" name="allowed_scopes" type="text" value="openid profile" />
                                <span class="field__hint">"スペース区切り。デフォルトは openid profile。"</span>
                            </div>
                            <div class="field">
                                <label for="c-logout" class="field__label">"Post-logout redirect URIs(任意)"</label>
                                <textarea id="c-logout" name="post_logout_redirect_uris" rows="2"></textarea>
                                <span class="field__hint">"1 行に 1 つ。"</span>
                            </div>
                            <label class="row" style="gap:var(--space-2)">
                                <input name="confidential" type="checkbox" value="true" checked=true />
                                <span>"Confidential client(client secret を発行する)"</span>
                            </label>
                            <div>
                                <button type="submit">"登録"</button>
                            </div>
                        </form>
                    </div>
                </section>

                <section>
                    <h2>"登録済みクライアント"</h2>
                    <div class="table-wrap">
                        <table>
                            <thead>
                                <tr>
                                    <th>"名前"</th>
                                    <th>"Client ID"</th>
                                    <th>"種別"</th>
                                    <th>"スコープ"</th>
                                    <th>"Logout URIs"</th>
                                    <th>"状態"</th>
                                    <th></th>
                                </tr>
                            </thead>
                            <tbody>{rows}</tbody>
                        </table>
                    </div>
                </section>
            </Shell>
        }
    })
}

// ---------- client edit ----------

pub struct ClientEditData {
    pub id: String,
    pub name: String,
    /// Newline-separated for textarea editing.
    pub redirect_uris: Vec<String>,
    /// Space-separated.
    pub allowed_scopes: String,
    pub post_logout_redirect_uris: Vec<String>,
    pub confidential: bool,
    pub is_disabled: bool,
}

pub fn render_client_edit(
    data: ClientEditData,
    flash: Option<Flash>,
    csrf_token: String,
) -> String {
    render(move || {
        let ClientEditData {
            id,
            name,
            redirect_uris,
            allowed_scopes,
            post_logout_redirect_uris,
            confidential,
            is_disabled,
        } = data;
        let post_url = format!("/admin/clients/{id}/edit");
        let kind = if confidential { "confidential" } else { "public" };
        let redirect_uris_value = redirect_uris.join("\n");
        let post_logout_value = post_logout_redirect_uris.join("\n");

        let status_badge = if is_disabled {
            view! { <span class="badge badge--warn">"disabled"</span> }.into_any()
        } else {
            view! { <span class="badge badge--ok">"active"</span> }.into_any()
        };

        view! {
            <Shell title="Edit client".to_string() show_nav=true current=Some("clients".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"クライアントを編集"</h1>
                        <p class="page-header__lede">{name.clone()}</p>
                    </div>
                </header>
                {flash_banner(flash)}

                <div class="card">
                    <h3 class="card__title">"基本情報"</h3>
                    <div class="stack-tight muted">
                        <div>"Client ID: "<span class="code">{id.clone()}</span></div>
                        <div class="row" style="gap:var(--space-2)">
                            <span>"種別:"</span>
                            <span class="badge badge--accent">{kind}</span>
                            <span>"状態:"</span>
                            {status_badge}
                        </div>
                    </div>
                    <p class="muted" style="margin-top:var(--space-3)">
                        "Client ID・種別(confidential/public)・client secret は作成時に固定されます。"
                        "これらを変更したい場合は削除して登録し直してください。"
                    </p>
                </div>

                <div class="card">
                    <h3 class="card__title">"設定"</h3>
                    <form method="post" action=post_url class="stack">
                        <input type="hidden" name="_csrf" value=csrf_token />
                        <div class="field">
                            <label for="e-name" class="field__label">"アプリケーション名"</label>
                            <input id="e-name" name="name" type="text" required=true value=name />
                        </div>
                        <div class="field">
                            <label for="e-uris" class="field__label">"Redirect URIs"</label>
                            <textarea id="e-uris" name="redirect_uris" required=true rows="3">
                                {redirect_uris_value}
                            </textarea>
                            <span class="field__hint">"1 行に 1 つ。https またはループバックの http のみ。"</span>
                        </div>
                        <div class="field">
                            <label for="e-scopes" class="field__label">"許可スコープ"</label>
                            <input id="e-scopes" name="allowed_scopes" type="text" value=allowed_scopes />
                            <span class="field__hint">"スペース区切り。空欄=制限なし。"</span>
                        </div>
                        <div class="field">
                            <label for="e-logout" class="field__label">"Post-logout redirect URIs"</label>
                            <textarea id="e-logout" name="post_logout_redirect_uris" rows="2">
                                {post_logout_value}
                            </textarea>
                            <span class="field__hint">"1 行に 1 つ。空欄=Redirect URIs を流用。"</span>
                        </div>
                        <div class="row">
                            <button type="submit">"保存"</button>
                            <a href="/admin/clients" class="button secondary">"キャンセル"</a>
                        </div>
                    </form>
                </div>
            </Shell>
        }
    })
}

// ---------- audit ----------

fn audit_row_view(e: AuditLogEntryDto) -> impl IntoView {
    let result_badge = match e.result.as_str() {
        "ok" => view! { <span class="badge badge--ok">"ok"</span> }.into_any(),
        "fail" | "error" | "denied" => {
            view! { <span class="badge badge--danger">{e.result.clone()}</span> }.into_any()
        }
        _ => view! { <span class="badge">{e.result.clone()}</span> }.into_any(),
    };
    view! {
        <tr>
            <td class="muted">{fmt_time(e.at)}</td>
            <td><span class="code">{e.actor.map(|a| a.to_string()).unwrap_or_else(|| "-".into())}</span></td>
            <td>{e.action}</td>
            <td><span class="code">{e.target.unwrap_or_default()}</span></td>
            <td>{result_badge}</td>
        </tr>
    }
}

pub fn render_audit(entries: Vec<AuditLogEntryDto>, flash: Option<Flash>) -> String {
    render(move || {
        let entry_count = entries.len();
        let rows: Vec<_> = entries.into_iter().map(audit_row_view).collect();
        view! {
            <Shell title="Audit".to_string() show_nav=true current=Some("audit".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"監査ログ"</h1>
                        <p class="page-header__lede">
                            "管理操作の履歴(新しい順)。"
                            {format!(" 直近 {entry_count} 件。")}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                <div class="table-wrap">
                    <table>
                        <thead>
                            <tr>
                                <th>"日時"</th>
                                <th>"実行者"</th>
                                <th>"操作"</th>
                                <th>"対象"</th>
                                <th>"結果"</th>
                            </tr>
                        </thead>
                        <tbody>{rows}</tbody>
                    </table>
                </div>
            </Shell>
        }
    })
}

// ---------- signing keys ----------

fn signing_key_row_view(
    k: sui_id_shared::api::SigningKeySummary,
    csrf: String,
) -> impl IntoView {
    let id_str = k.id.to_string();
    let id_for_url = id_str.clone();
    let id_for_display = id_str.clone();
    let status_badge = if k.is_active {
        view! { <span class="badge badge--ok">"active"</span> }.into_any()
    } else {
        view! { <span class="badge">"retired"</span> }.into_any()
    };
    let rotated = k
        .rotated_at
        .map(fmt_time)
        .unwrap_or_else(|| "-".to_string());
    let delete_url = format!("/admin/signing-keys/{id_for_url}/delete");
    let actions = if k.is_active {
        view! { <td><span class="muted">"(使用中)"</span></td> }.into_any()
    } else {
        view! {
            <td>
                <form method="post" action=delete_url style="display:inline"
                      onsubmit="return confirm('この退役キーを完全削除しますか? まだ有効期限内の発行済みトークンは検証に失敗します。');">
                    <input type="hidden" name="_csrf" value=csrf />
                    <button type="submit" class="danger">"削除"</button>
                </form>
            </td>
        }
        .into_any()
    };
    view! {
        <tr>
            <td><span class="code">{id_for_display}</span></td>
            <td>{k.algorithm}</td>
            <td>{status_badge}</td>
            <td class="muted">{fmt_time(k.created_at)}</td>
            <td class="muted">{rotated}</td>
            {actions}
        </tr>
    }
}

pub fn render_signing_keys(
    keys: Vec<sui_id_shared::api::SigningKeySummary>,
    flash: Option<Flash>,
    csrf_token: String,
) -> String {
    render(move || {
        let csrf_for_rows = csrf_token.clone();
        let csrf_for_form = csrf_token.clone();
        let key_count = keys.len();
        let rows: Vec<_> = keys
            .into_iter()
            .map(|k| signing_key_row_view(k, csrf_for_rows.clone()))
            .collect();
        view! {
            <Shell
                title="Signing keys".to_string()
                show_nav=true
                current=Some("signing-keys".to_string())
            >
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"署名キー"</h1>
                        <p class="page-header__lede">
                            "JWT 署名用 Ed25519 キーの管理。"
                            {format!(" {key_count} 件登録。")}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}

                <div class="card">
                    <h3 class="card__title">"キーローテーション"</h3>
                    <p class="muted">
                        "ローテーションを実行すると、新しい署名キーが発行され、現行キーは「退役」状態に遷移します。"
                        "退役キーは JWKS に残るため、有効期限内の既発行トークンは検証可能です。"
                        "それらが期限切れになった後、このページから安全に削除できます。"
                    </p>
                    <div class="card__footer">
                        <form method="post" action="/admin/signing-keys/rotate">
                            <input type="hidden" name="_csrf" value=csrf_for_form />
                            <button type="submit">"署名キーをローテーション"</button>
                        </form>
                    </div>
                </div>

                <section>
                    <h2>"全キー"</h2>
                    <div class="table-wrap">
                        <table>
                            <thead>
                                <tr>
                                    <th>"Key ID"</th>
                                    <th>"アルゴリズム"</th>
                                    <th>"状態"</th>
                                    <th>"作成日"</th>
                                    <th>"退役日"</th>
                                    <th></th>
                                </tr>
                            </thead>
                            <tbody>{rows}</tbody>
                        </table>
                    </div>
                </section>
            </Shell>
        }
    })
}

// ---------- error ----------

pub fn render_error(title: String, message: String, request_id: String) -> String {
    render(move || {
        let title2 = title.clone();
        view! {
            <crate::layout::AuthShell title=title.clone()>
                <h1>{title2}</h1>
                <div class="flash error" role="alert">{message}</div>
                <p class="muted">
                    "管理者に連絡する場合、以下の ID をお伝えください: "
                    <span class="code">{request_id}</span>
                </p>
                <p>
                    <a href="/" class="button secondary">"ホームへ戻る"</a>
                </p>
            </crate::layout::AuthShell>
        }
    })
}

// ---------- /me/security ----------
//
// Self-service security overview for the signed-in user. Shows where
// they are signed in, lets them revoke individual sessions or sign out
// everywhere else, and surfaces a user-scoped activity timeline so
// they have a chance to notice unusual events on their own account
// without an operator having to escalate.
//
// MFA management itself stays on `/admin/profile` (which is
// misleadingly named — it's "user profile", and a non-admin user can
// reach it the same way; the page does not require admin). We link
// to it from here rather than re-implement.

pub struct MeSecurityData {
    pub username: String,
    pub is_admin: bool,
    /// Whether the user has TOTP enrolled.
    pub totp_enabled: bool,
    /// Number of active WebAuthn passkeys.
    pub passkey_count: usize,
    /// Identifier of the session that issued the current request.
    /// Used to mark "this is you" in the session list and to keep it
    /// alive when the user clicks "sign out everywhere else".
    pub current_session_id: String,
    pub sessions: Vec<MeSessionDescriptor>,
    pub recent_events: Vec<MeAuditEntry>,
}

pub struct MeSessionDescriptor {
    pub id: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub expires_at: chrono::DateTime<chrono::Utc>,
    /// Comma-separated human display: "password", "password + TOTP", etc.
    pub auth_methods: String,
    pub is_current: bool,
}

pub struct MeAuditEntry {
    pub at: chrono::DateTime<chrono::Utc>,
    pub action: String,
    pub result: String,
    pub note: Option<String>,
}

pub fn render_me_security(
    data: MeSecurityData,
    flash: Option<Flash>,
    csrf_token: String,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        let MeSecurityData {
            username,
            is_admin,
            totp_enabled,
            passkey_count,
            current_session_id,
            sessions,
            recent_events,
        } = data;

        let csrf_for_revoke_others = csrf_token.clone();
        let revoke_label = t.me_security_sessions_revoke;
        let revoke_confirm = t.me_security_sessions_revoke_confirm.to_owned();
        let current_badge = t.me_security_sessions_current_badge;

        // Session table rows. Each non-current row gets its own
        // mini-form so a user can revoke that specific entry.
        let session_rows: Vec<_> = sessions
            .into_iter()
            .map(|s| {
                let MeSessionDescriptor {
                    id,
                    created_at,
                    expires_at,
                    auth_methods,
                    is_current,
                } = s;
                let when = created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
                let until = expires_at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
                let action_cell = if is_current {
                    view! {
                        <td>
                            <span class="badge badge--accent">{current_badge}</span>
                        </td>
                    }
                    .into_any()
                } else {
                    let csrf_for_row = csrf_token.clone();
                    let post_url = format!("/me/security/sessions/{id}/revoke");
                    let onsubmit_attr = format!("return confirm('{}');", revoke_confirm.replace('\'', "\\'"));
                    view! {
                        <td>
                            <form method="post" action=post_url style="display:inline"
                                  onsubmit=onsubmit_attr>
                                <input type="hidden" name="_csrf" value=csrf_for_row />
                                <button type="submit" class="secondary">{revoke_label}</button>
                            </form>
                        </td>
                    }
                    .into_any()
                };
                view! {
                    <tr>
                        <td class="muted">{when}</td>
                        <td class="muted">{until}</td>
                        <td>{auth_methods}</td>
                        {action_cell}
                    </tr>
                }
            })
            .collect();

        // Activity timeline.
        let event_rows: Vec<_> = recent_events
            .into_iter()
            .map(|e| {
                let MeAuditEntry {
                    at,
                    action,
                    result,
                    note,
                } = e;
                let when = at.format("%Y-%m-%d %H:%M:%S UTC").to_string();
                let note_str = note.unwrap_or_default();
                let result_badge = match result.as_str() {
                    "ok" => view! { <span class="badge badge--ok">"ok"</span> }.into_any(),
                    "fail" | "error" | "denied" => {
                        view! { <span class="badge badge--danger">{result.clone()}</span> }
                            .into_any()
                    }
                    _ => view! { <span class="badge">{result.clone()}</span> }.into_any(),
                };
                view! {
                    <tr>
                        <td class="muted">{when}</td>
                        <td><span class="code">{action}</span></td>
                        <td>{result_badge}</td>
                        <td class="muted">{note_str}</td>
                    </tr>
                }
            })
            .collect();

        let admin_link = is_admin.then(|| {
            view! {
                <p class="muted">
                    <a href="/admin">{t.me_security_admin_link}</a>
                </p>
            }
        });

        let mfa_summary = if totp_enabled || passkey_count > 0 {
            let parts = {
                let mut v = Vec::<String>::new();
                if totp_enabled {
                    v.push(t.me_security_mfa_factor_totp.to_owned());
                }
                if passkey_count > 0 {
                    v.push(
                        t.me_security_mfa_factor_passkey_n
                            .replace("{n}", &passkey_count.to_string()),
                    );
                }
                v.join(" / ")
            };
            view! {
                <p>
                    {t.me_security_mfa_status_label}
                    <span class="badge badge--ok" style="margin-left:var(--space-1)">{t.me_security_mfa_status_enabled}</span>
                    <span class="muted" style="margin-left:var(--space-2)">{parts}</span>
                </p>
            }
            .into_any()
        } else {
            view! {
                <div class="flash warn" role="status">
                    <div class="stack-tight">
                        <strong>{t.me_security_mfa_disabled_title}</strong>
                        <p class="muted" style="margin:0">{t.me_security_mfa_disabled_lede}</p>
                    </div>
                </div>
            }
            .into_any()
        };

        let revoke_all_others_onsubmit = format!(
            "return confirm('{}');",
            t.me_security_sessions_revoke_all_others_confirm.replace('\'', "\\'")
        );

        view! {
            <Shell title=t.me_security_title.to_owned() show_nav=false current=None lang=lang>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">{t.me_security_title}</h1>
                        <p class="page-header__lede">
                            <strong>{username}</strong>{t.me_security_signed_in_as_suffix}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {admin_link}

                <section>
                    <h2>{t.me_security_mfa_section}</h2>
                    <div class="card">
                        {mfa_summary}
                        <div class="card__footer">
                            <a href="/admin/profile" class="button secondary">
                                {t.me_security_mfa_manage}
                            </a>
                            <a href="/me/security/password" class="button secondary">
                                {t.me_security_password_change_link}
                            </a>
                        </div>
                    </div>
                </section>

                <section>
                    <h2>{t.me_security_sessions_section}</h2>
                    <p class="muted">{t.me_security_sessions_lede}</p>
                    <div class="table-wrap">
                        <table>
                            <thead>
                                <tr>
                                    <th>{t.me_security_sessions_th_started}</th>
                                    <th>{t.me_security_sessions_th_expires}</th>
                                    <th>{t.me_security_sessions_th_factors}</th>
                                    <th></th>
                                </tr>
                            </thead>
                            <tbody>{session_rows}</tbody>
                        </table>
                    </div>
                    <form method="post" action="/me/security/sessions/revoke-all-others"
                          style="margin-top:var(--space-3)"
                          onsubmit=revoke_all_others_onsubmit>
                        <input type="hidden" name="_csrf" value=csrf_for_revoke_others />
                        <input type="hidden" name="current_session" value=current_session_id />
                        <button type="submit" class="secondary">
                            {t.me_security_sessions_revoke_all_others}
                        </button>
                    </form>
                </section>

                <section>
                    <h2>{t.me_security_activity_section}</h2>
                    <p class="muted">{t.me_security_activity_lede}</p>
                    <div class="table-wrap">
                        <table>
                            <thead>
                                <tr>
                                    <th>{t.me_security_activity_th_when}</th>
                                    <th>{t.me_security_activity_th_event}</th>
                                    <th>{t.me_security_activity_th_result}</th>
                                    <th>{t.me_security_activity_th_note}</th>
                                </tr>
                            </thead>
                            <tbody>{event_rows}</tbody>
                        </table>
                    </div>
                </section>
            </Shell>
        }
    })
}

// ---------- /me/security/password ----------

pub struct PasswordChangeData {
    pub username: String,
    /// Pre-filled checked value of "sign out other sessions". The
    /// caller hands it in so a re-render after a validation error
    /// keeps the user's previous choice.
    pub revoke_others_default: bool,
}

pub fn render_password_change(
    data: PasswordChangeData,
    flash: Option<Flash>,
    csrf_token: String,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        let PasswordChangeData {
            username,
            revoke_others_default,
        } = data;
        let revoke_attr = if revoke_others_default { Some("") } else { None };
        view! {
            <Shell title=t.password_change_title.to_owned() show_nav=false current=None lang=lang>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">{t.password_change_title}</h1>
                        <p class="page-header__lede">
                            <strong>{username}</strong>{t.me_security_signed_in_as_suffix}
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}

                <div class="card" style="max-width:var(--content-narrow-width)">
                    <form method="post" action="/me/security/password"
                          autocomplete="off" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_token />

                        <div class="field">
                            <label for="current_password" class="field__label">{t.password_change_current_label}</label>
                            <input type="password" id="current_password" name="current_password"
                                   required autocomplete="current-password" />
                        </div>

                        <div class="field">
                            <label for="new_password" class="field__label">{t.password_change_new_label}</label>
                            <input type="password" id="new_password" name="new_password"
                                   required autocomplete="new-password" minlength="12" />
                            <span class="field__hint">{t.password_change_new_hint}</span>
                        </div>

                        <div class="field">
                            <label for="confirm_password" class="field__label">{t.password_change_confirm_label}</label>
                            <input type="password" id="confirm_password" name="confirm_password"
                                   required autocomplete="new-password" minlength="12" />
                        </div>

                        <div class="field">
                            <label class="row" style="gap:var(--space-2)">
                                <input type="checkbox" name="revoke_others" value="1"
                                       checked=revoke_attr />
                                <span>{t.password_change_revoke_others_label}</span>
                            </label>
                            <span class="field__hint">{t.password_change_revoke_others_hint}</span>
                        </div>

                        <div class="row">
                            <button type="submit">{t.password_change_submit}</button>
                            <a href="/me/security" class="button secondary">{t.button_cancel}</a>
                        </div>
                    </form>
                </div>
            </Shell>
        }
    })
}

// ---------- /admin/settings/* (v0.20.3) ----------
//
// Five read-only tabs surfacing the current effective configuration.
// Each tab is its own route; this view module just renders the
// shell + 5-tab strip + the tab body. The strip is intentionally
// styled with the same `.app-nav__link` vocabulary as the main nav,
// so the visual treatment is consistent: hover, focus ring, and the
// `aria-current="page"` pill all behave the same.

/// Identifier of the currently-active settings tab. The settings
/// page renders the same 5-tab strip on every sub-route; this enum
/// drives which tab gets `aria-current="page"`.
#[derive(Clone, Copy)]
pub enum SettingsTab {
    Basic,
    Security,
    Authentication,
    Logs,
    Email,
    Other,
}

impl SettingsTab {
    fn key(self) -> &'static str {
        match self {
            Self::Basic => "basic",
            Self::Security => "security",
            Self::Authentication => "authentication",
            Self::Logs => "logs",
            Self::Email => "email",
            Self::Other => "other",
        }
    }
}

fn settings_tabs(active: SettingsTab) -> impl IntoView {
    let items = [
        (SettingsTab::Basic, "基本", "/admin/settings/basic"),
        (SettingsTab::Security, "セキュリティ", "/admin/settings/security"),
        (
            SettingsTab::Authentication,
            "認証",
            "/admin/settings/authentication",
        ),
        (SettingsTab::Logs, "ログ", "/admin/settings/logs"),
        (SettingsTab::Email, "メール", "/admin/settings/email"),
        (SettingsTab::Other, "その他", "/admin/settings/other"),
    ];
    let active_key = active.key();
    let links: Vec<_> = items
        .into_iter()
        .map(|(t, label, href)| {
            let aria = if t.key() == active_key { Some("page") } else { None };
            view! {
                <a class="app-nav__link" href=href aria-current=aria>{label}</a>
            }
        })
        .collect();
    view! {
        <nav class="app-nav" aria-label="設定タブ" style="margin-bottom:var(--space-4);flex-wrap:wrap">
            {links}
        </nav>
    }
}

/// Two-column key/value table used inside each settings card. Keeps
/// per-tab content boring and consistent.
fn kv_row(k: &str, v: impl IntoView + 'static) -> impl IntoView {
    let k = k.to_owned();
    view! {
        <tr>
            <th scope="row" style="width:14rem;font-weight:var(--font-weight-medium);color:var(--fg-muted);text-align:left">
                {k}
            </th>
            <td>{v}</td>
        </tr>
    }
}

fn kv_text(k: &str, v: String) -> impl IntoView {
    kv_row(k, view! { <span>{v}</span> })
}

fn kv_code(k: &str, v: String) -> impl IntoView {
    kv_row(k, view! { <span class="code">{v}</span> })
}

fn kv_bool_badge(k: &str, on: bool) -> impl IntoView {
    let badge = if on {
        view! { <span class="badge badge--ok">"有効"</span> }.into_any()
    } else {
        view! { <span class="badge">"無効"</span> }.into_any()
    };
    kv_row(k, badge)
}

// ---------- 基本タブ ----------

pub struct SettingsBasicData {
    pub issuer: String,
    pub listen_addr: String,
    pub cookie_secure: bool,
    pub trusted_proxies: Vec<String>,
    pub discovery_url: String,
    pub jwks_url: String,
    /// Server-wide default UI language (BCP-47 tag, e.g. "ja").
    /// Comes from `server_settings.default_lang`. Editable via the
    /// form on this page; saved through `POST /admin/settings/basic/lang`.
    pub default_lang: String,
    /// CSRF token for the in-page edit form. Empty string when
    /// rendered without CSRF (legacy callers); the lang form
    /// no-ops when the token is missing.
    pub csrf_token: String,
}

pub fn render_settings_basic(data: SettingsBasicData, flash: Option<Flash>) -> String {
    render(move || {
        let SettingsBasicData {
            issuer,
            listen_addr,
            cookie_secure,
            trusted_proxies,
            discovery_url,
            jwks_url,
            default_lang,
            csrf_token,
        } = data;
        let csrf_for_lang = csrf_token.clone();
        let lang_form = view! {
            <section class="section">
                <h2 class="section__title">"サーバーデフォルト言語 / Server default language"</h2>
                <p class="muted">
                    "ユーザーの言語設定とブラウザの Accept-Language が一致しない場合の "
                    "フォールバックとして使用されます。 / Used as a fallback when no per-user "
                    "preference is set and Accept-Language does not match a supported locale."
                </p>
                <div class="card">
                    <form method="post" action="/admin/settings/basic/lang" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_for_lang />
                        <div class="field">
                            <label for="default-lang-select" class="field__label">
                                "言語 / Language"
                            </label>
                            <select id="default-lang-select" name="default_lang">
                                {sui_id_i18n::Locale::ALL.iter().map(|loc| {
                                    let tag = loc.tag();
                                    let selected = default_lang == tag;
                                    let label = loc.native_name();
                                    view! {
                                        <option value=tag selected=selected>{label}</option>
                                    }
                                }).collect::<Vec<_>>()}
                            </select>
                        </div>
                        <div>
                            <button type="submit">"保存 / Save"</button>
                        </div>
                    </form>
                </div>
            </section>
        };
        let proxies_display = if trusted_proxies.is_empty() {
            "(なし — peer の IP を直接信頼)".to_owned()
        } else {
            trusted_proxies.join(", ")
        };
        view! {
            <Shell title="設定 — 基本".to_string() show_nav=true current=Some("settings".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"設定"</h1>
                        <p class="page-header__lede">
                            "現在の有効な設定の確認。値の変更には sui-id.toml の編集と再起動が必要です。"
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {settings_tabs(SettingsTab::Basic)}

                <div class="card">
                    <h3 class="card__title">"基本"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_code("Issuer", issuer)}
                                {kv_code("Listen address", listen_addr)}
                                {kv_bool_badge("Cookie Secure フラグ", cookie_secure)}
                                {kv_text("Trusted proxies", proxies_display)}
                            </tbody>
                        </table>
                    </div>
                </div>

                <div class="card">
                    <h3 class="card__title">"OIDC 公開エンドポイント"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                <tr>
                                    <th scope="row" style="width:14rem;font-weight:var(--font-weight-medium);color:var(--fg-muted);text-align:left">"Discovery"</th>
                                    <td>
                                        {
                                            let url = discovery_url.clone();
                                            view! {
                                                <a href=discovery_url>
                                                    <span class="code">{url}</span>
                                                </a>
                                            }
                                        }
                                    </td>
                                </tr>
                                <tr>
                                    <th scope="row" style="width:14rem;font-weight:var(--font-weight-medium);color:var(--fg-muted);text-align:left">"JWKS"</th>
                                    <td>
                                        {
                                            let url = jwks_url.clone();
                                            view! {
                                                <a href=jwks_url>
                                                    <span class="code">{url}</span>
                                                </a>
                                            }
                                        }
                                    </td>
                                </tr>
                            </tbody>
                        </table>
                    </div>
                </div>
                {lang_form}
            </Shell>
        }
    })
}

// ---------- セキュリティタブ ----------

pub struct SettingsSecurityData {
    pub max_lockout_label: String,
    pub hsts_enabled: bool,
    pub csp_enabled: bool,
    pub x_frame_deny: bool,
    pub permissions_policy_minimal: bool,
    pub cors_token_dynamic_from_clients: bool,
    pub cors_public_endpoints_open: bool,
    /// v0.25.0 — current value in seconds, 0 = disabled.
    pub idle_session_timeout_secs: i64,
    /// v0.25.0 — current cap, 0 = disabled.
    pub max_concurrent_sessions: i64,
    /// CSRF token for the inline edit forms. Empty string is
    /// tolerated (forms no-op without it) but production callers
    /// should always pass a real token.
    pub csrf_token: String,
}

pub fn render_settings_security(data: SettingsSecurityData, flash: Option<Flash>) -> String {
    render(move || {
        let SettingsSecurityData {
            max_lockout_label,
            hsts_enabled,
            csp_enabled,
            x_frame_deny,
            permissions_policy_minimal,
            cors_token_dynamic_from_clients,
            cors_public_endpoints_open,
            idle_session_timeout_secs,
            max_concurrent_sessions,
            csrf_token,
        } = data;
        let csrf_for_idle = csrf_token.clone();
        let csrf_for_cap = csrf_token.clone();
        let session_forms = view! {
            <section class="section">
                <h2 class="section__title">"セッション制限 / Session limits"</h2>
                <p class="muted">
                    "アイドルタイムアウトと同時セッション数の上限。 "
                    "いずれも 0 で無効。デフォルトは 0 (無効) で、運用ポリシーに応じて opt-in。 "
                    "/ Idle timeout and concurrent-session cap. Both default to 0 (disabled); "
                    "enable per policy."
                </p>
                <div class="card">
                    <form method="post" action="/admin/settings/security/idle-timeout" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_for_idle />
                        <div class="field">
                            <label for="idle-timeout" class="field__label">
                                "アイドルタイムアウト (秒) / Idle timeout (seconds)"
                            </label>
                            <input id="idle-timeout" name="secs" type="number"
                                   min="0" max="2592000"
                                   value=idle_session_timeout_secs.to_string() />
                            <span class="field__hint">
                                "0 = 無効。0 < N <= 2,592,000 (= 30 日)。 / 0 disables; up to 30 days."
                            </span>
                        </div>
                        <div>
                            <button type="submit">"保存 / Save"</button>
                        </div>
                    </form>
                </div>
                <div class="card">
                    <form method="post" action="/admin/settings/security/max-sessions" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_for_cap />
                        <div class="field">
                            <label for="max-sessions" class="field__label">
                                "1 ユーザーあたり最大同時セッション数 / Max concurrent sessions per user"
                            </label>
                            <input id="max-sessions" name="cap" type="number"
                                   min="0" max="1000"
                                   value=max_concurrent_sessions.to_string() />
                            <span class="field__hint">
                                "0 = 無効。1 <= N <= 1000。超過時は最も古いセッションが自動 revoke (FIFO)。 / "
                                "0 disables. When exceeded, oldest session is auto-revoked (FIFO)."
                            </span>
                        </div>
                        <div>
                            <button type="submit">"保存 / Save"</button>
                        </div>
                    </form>
                </div>
            </section>
        };
        view! {
            <Shell title="設定 — セキュリティ".to_string() show_nav=true current=Some("settings".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"設定"</h1>
                        <p class="page-header__lede">
                            "現在の有効な設定の確認。値の変更には sui-id.toml の編集と再起動が必要です。"
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {settings_tabs(SettingsTab::Security)}

                <div class="card">
                    <h3 class="card__title">"アカウントロックアウト"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_code("最大ロックアウト時間", max_lockout_label)}
                            </tbody>
                        </table>
                    </div>
                    <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                        "段階的バックオフの上限値。プログレッシブな失敗時間が積み重なってもこの値を超えません。"
                        "管理者は "<span class="code">"sui-id admin unlock-user"</span>" コマンドでいつでも解除できます。"
                    </p>
                </div>

                <div class="card">
                    <h3 class="card__title">"セキュリティヘッダー"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_bool_badge("HSTS (Strict-Transport-Security)", hsts_enabled)}
                                {kv_bool_badge("Content-Security-Policy", csp_enabled)}
                                {kv_bool_badge("X-Frame-Options: DENY", x_frame_deny)}
                                {kv_bool_badge("Permissions-Policy(最小)", permissions_policy_minimal)}
                            </tbody>
                        </table>
                    </div>
                    <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                        "管理画面はすべて上記ヘッダーを返します。"
                        "/oauth2/* 系の公開エンドポイントは仕様上の必要に応じて一部省略します。"
                    </p>
                </div>

                <div class="card">
                    <h3 class="card__title">"CORS"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_bool_badge("Token endpoint の動的許可(登録 redirect_uris の origin)", cors_token_dynamic_from_clients)}
                                {kv_bool_badge("Discovery / JWKS / userinfo の公開許可(*)", cors_public_endpoints_open)}
                            </tbody>
                        </table>
                    </div>
                </div>
                {session_forms}
            </Shell>
        }
    })
}

// ---------- 認証タブ ----------

pub struct SettingsAuthenticationData {
    pub password_min_length: usize,
    pub password_argon2id: String,
    pub totp_enabled_per_user: bool,
    pub webauthn_enabled_per_user: bool,
    pub recovery_codes_per_enrollment: usize,
    pub pkce_required: bool,
    pub access_token_lifetime_secs: i64,
    pub id_token_lifetime_secs: i64,
    pub refresh_token_lifetime_secs: i64,
    pub refresh_rotation: bool,
    pub refresh_theft_detection: bool,
}

fn fmt_lifetime(secs: i64) -> String {
    if secs % 86400 == 0 {
        format!("{} 日 ({secs}s)", secs / 86400)
    } else if secs % 3600 == 0 {
        format!("{} 時間 ({secs}s)", secs / 3600)
    } else if secs % 60 == 0 {
        format!("{} 分 ({secs}s)", secs / 60)
    } else {
        format!("{secs} s")
    }
}

pub fn render_settings_authentication(
    data: SettingsAuthenticationData,
    flash: Option<Flash>,
) -> String {
    render(move || {
        let SettingsAuthenticationData {
            password_min_length,
            password_argon2id,
            totp_enabled_per_user,
            webauthn_enabled_per_user,
            recovery_codes_per_enrollment,
            pkce_required,
            access_token_lifetime_secs,
            id_token_lifetime_secs,
            refresh_token_lifetime_secs,
            refresh_rotation,
            refresh_theft_detection,
        } = data;
        view! {
            <Shell title="設定 — 認証".to_string() show_nav=true current=Some("settings".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"設定"</h1>
                        <p class="page-header__lede">
                            "現在の有効な設定の確認。値の変更には sui-id.toml の編集と再起動が必要です。"
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {settings_tabs(SettingsTab::Authentication)}

                <div class="card">
                    <h3 class="card__title">"パスワード"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_text("最小文字数", format!("{password_min_length} 文字"))}
                                {kv_text("ハッシュアルゴリズム", password_argon2id)}
                            </tbody>
                        </table>
                    </div>
                </div>

                <div class="card">
                    <h3 class="card__title">"2 段階認証"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_bool_badge("TOTP(認証アプリ)", totp_enabled_per_user)}
                                {kv_bool_badge("WebAuthn(パスキー)", webauthn_enabled_per_user)}
                                {kv_text("リカバリーコード(登録ごと)", format!("{recovery_codes_per_enrollment} 件"))}
                            </tbody>
                        </table>
                    </div>
                    <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                        "ユーザー個別に有効化します。詳細は "
                        <a href="/admin/profile">"/admin/profile"</a>
                        " を参照してください。"
                    </p>
                </div>

                <div class="card">
                    <h3 class="card__title">"OAuth 2.1 / OIDC"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_bool_badge("PKCE 必須(全 client、全 flow)", pkce_required)}
                                {kv_text("Access token 有効期限", fmt_lifetime(access_token_lifetime_secs))}
                                {kv_text("ID token 有効期限", fmt_lifetime(id_token_lifetime_secs))}
                                {kv_text("Refresh token 有効期限", fmt_lifetime(refresh_token_lifetime_secs))}
                                {kv_bool_badge("Refresh ローテーション", refresh_rotation)}
                                {kv_bool_badge("Refresh 盗難検知(family revoke)", refresh_theft_detection)}
                            </tbody>
                        </table>
                    </div>
                </div>
            </Shell>
        }
    })
}

// ---------- ログタブ ----------

pub struct SettingsLogsData {
    pub log_format: String,
    pub log_filter: String,
    pub login_success_24h: i64,
    pub login_failure_24h: i64,
    pub login_locked_24h: i64,
    pub password_changed_self_24h: i64,
    pub chain_report: SettingsChainStatus,
}

pub struct SettingsChainStatus {
    pub checked: usize,
    pub broken_at_seq: Option<i64>,
    pub legacy_unhashed: usize,
}

pub fn render_settings_logs(data: SettingsLogsData, flash: Option<Flash>) -> String {
    render(move || {
        let SettingsLogsData {
            log_format,
            log_filter,
            login_success_24h,
            login_failure_24h,
            login_locked_24h,
            password_changed_self_24h,
            chain_report,
        } = data;

        let chain_badge = if chain_report.broken_at_seq.is_some() {
            view! { <span class="badge badge--danger">"破損検知"</span> }.into_any()
        } else {
            view! { <span class="badge badge--ok">"正常"</span> }.into_any()
        };
        let chain_note = match chain_report.broken_at_seq {
            Some(seq) => format!("seq={seq} で不一致を検出。すぐに調査してください。"),
            None => format!(
                "末尾 {} 行を検査。レガシー(v0.17 以前)未ハッシュ行: {}",
                chain_report.checked, chain_report.legacy_unhashed
            ),
        };

        view! {
            <Shell title="設定 — ログ".to_string() show_nav=true current=Some("settings".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"設定"</h1>
                        <p class="page-header__lede">
                            "ログ出力設定と監査ログの状態。"
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {settings_tabs(SettingsTab::Logs)}

                <div class="card">
                    <h3 class="card__title">"ログ出力"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_code("Format", log_format)}
                                {kv_code("Filter", log_filter)}
                            </tbody>
                        </table>
                    </div>
                </div>

                <div class="card">
                    <h3 class="card__title">"直近 24 時間のイベント"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_text("auth.login.success", login_success_24h.to_string())}
                                {kv_text("auth.login.failure", login_failure_24h.to_string())}
                                {kv_text("auth.login.locked", login_locked_24h.to_string())}
                                {kv_text("auth.password.changed_self", password_changed_self_24h.to_string())}
                            </tbody>
                        </table>
                    </div>
                    <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                        "詳細な履歴は "<a href="/admin/audit">"/admin/audit"</a>" を参照してください。"
                    </p>
                </div>

                <div class="card">
                    <h3 class="card__title">"監査ログのハッシュチェーン"</h3>
                    <div class="row" style="gap:var(--space-3);align-items:center">
                        <span>"状態:"</span>
                        {chain_badge}
                    </div>
                    <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                        {chain_note}
                    </p>
                </div>
            </Shell>
        }
    })
}

// ---------- その他タブ ----------

pub struct SettingsOtherData {
    pub binary_version: String,
    pub schema_version: i32,
    pub db_path: String,
    pub master_key_file: String,
    pub user_count: usize,
    pub client_count: usize,
    pub clock_now: chrono::DateTime<chrono::Utc>,
}

pub fn render_settings_other(data: SettingsOtherData, flash: Option<Flash>) -> String {
    render(move || {
        let SettingsOtherData {
            binary_version,
            schema_version,
            db_path,
            master_key_file,
            user_count,
            client_count,
            clock_now,
        } = data;
        let now_str = clock_now.format("%Y-%m-%d %H:%M:%S UTC").to_string();
        view! {
            <Shell title="設定 — その他".to_string() show_nav=true current=Some("settings".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"設定"</h1>
                        <p class="page-header__lede">
                            "ビルド情報・スキーマ・ストレージ。"
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {settings_tabs(SettingsTab::Other)}

                <div class="card">
                    <h3 class="card__title">"ビルド"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_code("sui-id バージョン", binary_version)}
                                {kv_text("対応スキーマバージョン", schema_version.to_string())}
                                {kv_code("サーバ時刻", now_str)}
                            </tbody>
                        </table>
                    </div>
                </div>

                <div class="card">
                    <h3 class="card__title">"ストレージ"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                {kv_code("DB ファイル", db_path)}
                                {kv_code("マスターキーファイル", master_key_file)}
                            </tbody>
                        </table>
                    </div>
                    <p class="muted" style="margin-top:var(--space-2);margin-bottom:0">
                        "DB は単一の SQLite ファイル、マスターキーは環境変数 "
                        <span class="code">"SUI_ID_MASTER_KEY"</span>
                        " が指定されない場合のみキーファイルから読み込まれます。"
                    </p>
                </div>

                <div class="card">
                    <h3 class="card__title">"レコード数"</h3>
                    <div class="table-wrap">
                        <table>
                            <tbody>
                                <tr>
                                    <th scope="row" style="width:14rem;font-weight:var(--font-weight-medium);color:var(--fg-muted);text-align:left">"ユーザー"</th>
                                    <td>
                                        {user_count.to_string()}" 名 "
                                        <a href="/admin/users" class="muted" style="margin-left:var(--space-2)">
                                            "管理 →"
                                        </a>
                                    </td>
                                </tr>
                                <tr>
                                    <th scope="row" style="width:14rem;font-weight:var(--font-weight-medium);color:var(--fg-muted);text-align:left">"クライアント"</th>
                                    <td>
                                        {client_count.to_string()}" 件 "
                                        <a href="/admin/clients" class="muted" style="margin-left:var(--space-2)">
                                            "管理 →"
                                        </a>
                                    </td>
                                </tr>
                            </tbody>
                        </table>
                    </div>
                </div>
            </Shell>
        }
    })
}

// ---------- /me/security/step-up (v0.21.0) ----------
//
// Step-up challenge form. Renders inside the same chrome the rest
// of /me/* uses, but with a narrower main column to focus the
// user on the single thing the page is asking for: a TOTP / passkey
// proof to unlock the next sensitive action.

pub fn render_step_up(
    return_to: &str,
    csrf_token: String,
    has_passkey: bool,
    flash: Option<Flash>,
    lang: sui_id_i18n::Locale,
) -> String {
    let return_to = return_to.to_owned();
    render(move || {
        let t = lang.strings();
        let return_to_for_input = return_to.clone();
        let csrf_for_passkey = csrf_token.clone();
        let return_to_for_passkey = return_to.clone();
        let passkey_block = if has_passkey {
            view! {
                <hr class="divider" />
                <p class="muted">{format!("{}:", t.step_up_passkey_alt)}</p>
                <form id="step-up-passkey-form" method="post"
                      action="/me/security/step-up/webauthn/start"
                      class="stack">
                    <input type="hidden" name="_csrf" value=csrf_for_passkey />
                    <input type="hidden" name="return_to" value=return_to_for_passkey />
                    <button type="submit" class="secondary">{t.step_up_passkey_button}</button>
                </form>
                <script src="/static/step-up-webauthn.js"></script>
            }
            .into_any()
        } else {
            view! { <></> }.into_any()
        };
        view! {
            <Shell title=t.step_up_title.to_string() show_nav=false current=None lang=lang>
                <div class="auth-page">
                    <div class="auth-card">
                        <h1>{t.step_up_title}</h1>
                        <p class="muted">{t.step_up_lede}</p>
                        {flash_banner(flash)}
                        <form method="post" action="/me/security/step-up"
                              autocomplete="off" class="stack">
                            <input type="hidden" name="_csrf" value=csrf_token />
                            <input type="hidden" name="return_to" value=return_to_for_input />
                            <div class="field">
                                <label for="code" class="field__label">{t.step_up_code_label}</label>
                                <input id="code" name="code" type="text"
                                       required=true
                                       autocomplete="one-time-code"
                                       inputmode="text"
                                       autofocus=true />
                                <span class="field__hint">{t.step_up_code_hint}</span>
                            </div>
                            <div class="row">
                                <button type="submit">{t.button_confirm}</button>
                                <a href="/me/security" class="button secondary">{t.button_cancel}</a>
                            </div>
                        </form>
                        {passkey_block}
                    </div>
                </div>
            </Shell>
        }
    })
}

// ---------- /forgot-password & /reset-password (v0.22.0) ----------

pub fn render_forgot_password(
    csrf_token: String,
    flash: Option<Flash>,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.forgot_password_title.to_string() lang=lang>
                <h1>{t.forgot_password_title}</h1>
                <p class="muted">{t.forgot_password_lede}</p>
                {flash_banner(flash)}
                <form method="post" action="/forgot-password" class="stack">
                    <input type="hidden" name="_csrf" value=csrf_token />
                    <div class="field">
                        <label for="email" class="field__label">{t.forgot_password_email_label}</label>
                        <input id="email" name="email" type="email"
                               required=true autocomplete="email" autofocus=true />
                    </div>
                    <div class="row">
                        <button type="submit">{t.forgot_password_submit}</button>
                        <a href="/admin/login" class="button secondary">{t.back_to_login}</a>
                    </div>
                </form>
            </crate::layout::AuthShell>
        }
    })
}

pub fn render_forgot_password_sent(lang: sui_id_i18n::Locale) -> String {
    render(move || {
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.forgot_password_sent_title.to_string() lang=lang>
                <h1>{t.forgot_password_sent_title}</h1>
                <p class="muted">{t.forgot_password_sent_lede}</p>
                <p class="muted">{t.forgot_password_sent_lede2}</p>
                <p style="margin-top:var(--space-4)">
                    <a href="/admin/login" class="button">{t.back_to_login}</a>
                </p>
            </crate::layout::AuthShell>
        }
    })
}

pub fn render_reset_password(
    token: String,
    csrf_token: String,
    flash: Option<Flash>,
    lang: sui_id_i18n::Locale,
) -> String {
    render(move || {
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.reset_password_title.to_string() lang=lang>
                <h1>{t.reset_password_title}</h1>
                <p class="muted">{t.reset_password_lede}</p>
                {flash_banner(flash)}
                <form method="post" action="/reset-password" class="stack" autocomplete="off">
                    <input type="hidden" name="_csrf" value=csrf_token />
                    <input type="hidden" name="token" value=token />
                    <div class="field">
                        <label for="password" class="field__label">{t.reset_password_new_label}</label>
                        <input id="password" name="password" type="password"
                               required=true minlength="12"
                               autocomplete="new-password" autofocus=true />
                        <span class="field__hint">{t.reset_password_new_hint}</span>
                    </div>
                    <div class="field">
                        <label for="confirm_password" class="field__label">{t.reset_password_confirm_label}</label>
                        <input id="confirm_password" name="confirm_password" type="password"
                               required=true minlength="12"
                               autocomplete="new-password" />
                    </div>
                    <button type="submit">{t.reset_password_submit}</button>
                </form>
            </crate::layout::AuthShell>
        }
    })
}

pub fn render_reset_password_invalid(lang: sui_id_i18n::Locale) -> String {
    render(move || {
        let t = lang.strings();
        view! {
            <crate::layout::AuthShell title=t.reset_password_invalid_title.to_string() lang=lang>
                <h1>{t.reset_password_invalid_title}</h1>
                <p class="muted">{t.reset_password_invalid_lede}</p>
                <p style="margin-top:var(--space-4)">
                    <a href="/forgot-password" class="button">{t.reset_password_invalid_request_again}</a>
                </p>
            </crate::layout::AuthShell>
        }
    })
}

// ---------- /admin/settings/email (v0.22.0) ----------

pub struct SettingsEmailData {
    pub configured: bool,
    pub enabled: bool,
    pub host: String,
    pub port: u16,
    pub tls_mode: String,
    pub username: String,
    pub has_password: bool,
    pub from_address: String,
    pub from_name: String,
    pub base_url: String,
}

pub fn render_settings_email(
    data: SettingsEmailData,
    csrf_token: String,
    flash: Option<Flash>,
) -> String {
    render(move || {
        let SettingsEmailData {
            configured: _,
            enabled,
            host,
            port,
            tls_mode,
            username,
            has_password,
            from_address,
            from_name,
            base_url,
        } = data;
        let csrf_save = csrf_token.clone();
        let csrf_test = csrf_token.clone();
        let port_str = port.to_string();
        let pw_placeholder = if has_password {
            "(変更する場合のみ入力)"
        } else {
            "(認証不要の場合は空欄)"
        };
        let enabled_attr = if enabled { Some("checked") } else { None };
        let tls_implicit = if tls_mode == "implicit" { Some("selected") } else { None };
        let tls_starttls = if tls_mode == "starttls" { Some("selected") } else { None };

        view! {
            <Shell title="設定 — メール".to_string() show_nav=true current=Some("settings".to_string())>
                <header class="page-header">
                    <div>
                        <h1 class="page-header__title">"設定"</h1>
                        <p class="page-header__lede">
                            "メール送信(SMTP)設定。パスワードリセットや変更通知の送信に使用します。"
                        </p>
                    </div>
                </header>
                {flash_banner(flash)}
                {settings_tabs(SettingsTab::Email)}

                <div class="card">
                    <h3 class="card__title">"SMTP 接続"</h3>
                    <form method="post" action="/admin/settings/email" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_save />
                        <div class="field">
                            <label class="field__label">
                                <input type="checkbox" name="enabled" value="on" checked=enabled_attr />
                                " メール機能を有効化"
                            </label>
                            <span class="field__hint">
                                "オフの場合、forgot-password などのメール送信エンドポイントは無効になります。"
                            </span>
                        </div>
                        <div class="field">
                            <label for="host" class="field__label">"SMTP ホスト"</label>
                            <input id="host" name="host" type="text" required=true value=host />
                        </div>
                        <div class="field">
                            <label for="port" class="field__label">"ポート"</label>
                            <input id="port" name="port" type="number" min="1" max="65535"
                                   required=true value=port_str />
                            <span class="field__hint">"587(STARTTLS)または 465(暗黙 TLS)が一般的です。"</span>
                        </div>
                        <div class="field">
                            <label for="tls_mode" class="field__label">"TLS モード"</label>
                            <select id="tls_mode" name="tls_mode">
                                <option value="starttls" selected=tls_starttls>"STARTTLS (587)"</option>
                                <option value="implicit" selected=tls_implicit>"暗黙 TLS (465)"</option>
                            </select>
                        </div>
                        <div class="field">
                            <label for="username" class="field__label">"ユーザー名(任意)"</label>
                            <input id="username" name="username" type="text"
                                   autocomplete="off" value=username />
                        </div>
                        <div class="field">
                            <label for="password" class="field__label">"パスワード"</label>
                            <input id="password" name="password" type="password"
                                   autocomplete="off" placeholder=pw_placeholder />
                            <span class="field__hint">
                                "保存済みのパスワードを変更する場合のみ入力してください。空欄の場合は既存値を保持します。"
                            </span>
                        </div>
                        <hr class="divider" />
                        <div class="field">
                            <label for="from_address" class="field__label">"送信元アドレス"</label>
                            <input id="from_address" name="from_address" type="email"
                                   required=true value=from_address />
                        </div>
                        <div class="field">
                            <label for="from_name" class="field__label">"送信元表示名(任意)"</label>
                            <input id="from_name" name="from_name" type="text" value=from_name />
                        </div>
                        <div class="field">
                            <label for="base_url" class="field__label">"公開 Base URL"</label>
                            <input id="base_url" name="base_url" type="url"
                                   required=true value=base_url
                                   placeholder="https://idp.example.com" />
                            <span class="field__hint">
                                "リセットメールに記載する URL のベース。Issuer URL とは別に明示できます。"
                            </span>
                        </div>
                        <button type="submit">"設定を保存"</button>
                    </form>
                </div>

                <div class="card">
                    <h3 class="card__title">"接続テスト"</h3>
                    <p class="muted">
                        "現在の設定で SMTP サーバーへの接続と認証を試みます。"
                        "メールは送信されません。"
                    </p>
                    <form method="post" action="/admin/settings/email/test" class="stack">
                        <input type="hidden" name="_csrf" value=csrf_test />
                        <button type="submit" class="secondary">"接続をテスト"</button>
                    </form>
                </div>
            </Shell>
        }
    })
}
