5 Commits

Author SHA1 Message Date
ab a65488c304 Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s
2026-05-19 00:57:05 +03:00
ab 4d9d0a894c Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 2m53s
2026-05-19 00:32:36 +03:00
ab fd1e78ba8c Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m49s
2026-05-19 00:16:22 +03:00
ab 99e2cbc1f0 Added OIDC auth
Build and Publish / Build and Publish Docker Image (push) Successful in 1m51s
2026-05-18 23:50:34 +03:00
ab 71f444b9aa Added claudflare Turnstile captcha support 2026-05-18 23:09:07 +03:00
7 changed files with 537 additions and 38 deletions
Generated
+9 -1
View File
@@ -3182,6 +3182,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3353,8 +3359,9 @@ dependencies = [
[[package]]
name = "web-petting"
version = "0.1.11"
version = "0.1.12"
dependencies = [
"base64",
"chrono",
"chrono-tz",
"cot",
@@ -3368,6 +3375,7 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
"urlencoding",
"uuid",
]
+3 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "web-petting"
version = "0.1.11"
version = "0.1.12"
edition = "2024"
[dependencies]
@@ -16,5 +16,7 @@ multer = "3"
futures = "0.3"
tokio = { version = "1", features = ["fs"] }
uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
urlencoding = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+383 -1
View File
@@ -88,6 +88,15 @@ fn has_query_flag(request: &Request, flag: &str) -> bool {
.unwrap_or(false)
}
fn get_query_param(request: &Request, key: &str) -> Option<String> {
let prefix = format!("{}=", key);
request.uri().query().and_then(|q| {
q.split('&').find_map(|p| {
p.strip_prefix(&prefix).map(|v| v.to_string())
})
})
}
/// Soft pastel palette for client calendar colors.
const CLIENT_COLORS: &[&str] = &[
"#7c6ed4", "#5b9bd5", "#4caf93", "#e0915e", "#d46c8e", "#8e6bbf", "#5cb8a5", "#c77c4f",
@@ -194,6 +203,8 @@ struct LoginTemplate<'a> {
lang: Lang,
error: Option<String>,
turnstile_site_key: String,
auth_password_enabled: bool,
auth_sso_enabled: bool,
}
#[derive(Debug, Template)]
@@ -255,6 +266,8 @@ struct SettingsTemplate<'a> {
admin_name: &'a str,
settings: Vec<Setting>,
saved: bool,
auth_password_checked: bool,
auth_sso_checked: bool,
}
#[derive(Debug, Template)]
@@ -348,11 +361,54 @@ async fn login_page(request: Request, session: Session, db: Database) -> cot::Re
}
let turnstile_site_key = crate::turnstile::get_site_key(&db).await?;
let settings = Setting::objects().all(&db).await?;
let get_val = |key: &str| -> String {
settings
.iter()
.find(|s| s.key == key)
.map(|s| s.value.clone())
.unwrap_or_default()
};
let password_setting = get_val("auth_password_enabled");
let sso_setting = get_val("auth_sso_enabled");
let oidc_configured = !get_val("oidc_issuer_url").trim().is_empty();
// Default: password enabled if setting was never saved
let auth_password_enabled = if password_setting.is_empty() {
true
} else {
password_setting == "true"
};
let auth_sso_enabled = sso_setting == "true" && oidc_configured;
// Fallback: if neither is enabled, show password form
let (auth_password_enabled, auth_sso_enabled) = if !auth_password_enabled && !auth_sso_enabled {
(true, false)
} else {
(auth_password_enabled, auth_sso_enabled)
};
let error = get_query_param(&request, "error").map(|code| {
let t = lang.t();
match code.as_str() {
"sso_group" => t.login_sso_error_group,
"sso_provider" => t.login_sso_error_provider,
"sso_disabled" => t.login_sso_error_user_disabled,
"sso" => t.login_sso_error,
_ => t.login_sso_error,
}
.to_string()
});
let body = LoginTemplate {
t: lang.t(),
lang,
error: None,
error,
turnstile_site_key,
auth_password_enabled,
auth_sso_enabled,
}
.render()?;
html_response(body, lang)
@@ -442,6 +498,8 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
lang,
error: Some(lang.t().login_error.to_string()),
turnstile_site_key,
auth_password_enabled: true,
auth_sso_enabled: false,
}
.render()?;
return html_response(body, lang);
@@ -471,6 +529,8 @@ async fn login_submit(request: Request, session: Session, db: Database) -> cot::
lang,
error: Some(lang.t().login_error.to_string()),
turnstile_site_key,
auth_password_enabled: true,
auth_sso_enabled: false,
}
.render()?;
html_response(body, lang)
@@ -482,6 +542,274 @@ async fn logout(request: Request, session: Session) -> cot::Result<Response> {
Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response()
}
// ---------------------------------------------------------------------------
// OIDC Handlers
// ---------------------------------------------------------------------------
/// Read an OIDC-related setting from the DB, returning empty string if absent.
async fn oidc_setting(db: &Database, name: &str) -> cot::Result<String> {
let k = name.to_string();
Ok(query!(Setting, $key == k)
.get(db)
.await?
.map(|s| s.value)
.unwrap_or_default())
}
/// Fetch the OpenID Connect discovery document and extract a field.
async fn oidc_discover(issuer_url: &str, field: &str) -> Option<String> {
let url = format!(
"{}/.well-known/openid-configuration",
issuer_url.trim_end_matches('/')
);
let resp = reqwest::Client::new().get(&url).send().await.ok()?;
let json: serde_json::Value = resp.json().await.ok()?;
json.get(field)?.as_str().map(|s| s.to_string())
}
/// Decode the payload of a JWT (base64url, no signature verification).
fn decode_jwt_payload(token: &str) -> Option<serde_json::Value> {
use base64::Engine;
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return None;
}
let payload = parts[1];
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
serde_json::from_slice(&bytes).ok()
}
async fn oidc_start(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
let client_id = oidc_setting(&db, "oidc_client_id").await?;
let site_domain = oidc_setting(&db, "site_domain").await?;
if issuer_url.trim().is_empty() || client_id.trim().is_empty() {
return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code()))
.into_response();
}
let authorization_endpoint = match oidc_discover(&issuer_url, "authorization_endpoint").await {
Some(ep) => ep,
None => {
return Redirect::new(format!("/admin/login?lang={}&error=sso_provider", lang.code()))
.into_response();
}
};
let state = rand_token();
let redirect_uri = format!(
"{}/admin/oidc/callback",
site_domain.trim_end_matches('/')
);
let redirect_url = format!(
"{}?response_type=code&client_id={}&redirect_uri={}&scope=openid+profile&state={}",
authorization_endpoint,
urlencoding::encode(&client_id),
urlencoding::encode(&redirect_uri),
urlencoding::encode(&state),
);
let state_cookie = format!(
"oidc_state={}; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=600",
state,
);
Redirect::new(redirect_url)
.into_response()?
.with_header("set-cookie", state_cookie)
.into_response()
}
async fn oidc_callback(request: Request, session: Session, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
let fail = |code: &str| format!("/admin/login?lang={}&error={}", lang.code(), code);
// Read saved state from cookie
let saved_state = request
.headers()
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|part| {
let part = part.trim();
part.strip_prefix("oidc_state=").map(|v| v.to_string())
})
})
.unwrap_or_default();
// Extract code and state from query string
let query_str = request.uri().query().unwrap_or("");
let mut code = String::new();
let mut state = String::new();
for pair in query_str.split('&') {
if let Some(v) = pair.strip_prefix("code=") {
code = v.to_string();
} else if let Some(v) = pair.strip_prefix("state=") {
state = v.to_string();
}
}
if code.is_empty() || state.is_empty() || state != saved_state {
tracing::warn!(
"OIDC state mismatch: state={state:?}, saved={saved_state:?}, code_empty={}, state_empty={}",
code.is_empty(),
state.is_empty(),
);
return Redirect::new(fail("sso")).into_response();
}
let issuer_url = oidc_setting(&db, "oidc_issuer_url").await?;
let client_id = oidc_setting(&db, "oidc_client_id").await?;
let client_secret = oidc_setting(&db, "oidc_client_secret").await?;
let site_domain = oidc_setting(&db, "site_domain").await?;
// Get token endpoint from discovery
let token_endpoint = match oidc_discover(&issuer_url, "token_endpoint").await {
Some(ep) => ep,
None => {
tracing::warn!("OIDC discovery failed for issuer_url={issuer_url:?}");
return Redirect::new(fail("sso_provider")).into_response();
}
};
let redirect_uri = format!(
"{}/admin/oidc/callback",
site_domain.trim_end_matches('/')
);
// Exchange code for tokens
let token_resp = reqwest::Client::new()
.post(&token_endpoint)
.form(&[
("grant_type", "authorization_code"),
("code", &code),
("redirect_uri", &redirect_uri),
("client_id", &client_id),
("client_secret", &client_secret),
])
.send()
.await;
let token_json: serde_json::Value = match token_resp {
Ok(resp) => match resp.json().await {
Ok(v) => v,
Err(e) => {
tracing::warn!("OIDC token response parse error: {e}");
return Redirect::new(fail("sso_provider")).into_response();
}
},
Err(e) => {
tracing::warn!("OIDC token request failed: {e}");
return Redirect::new(fail("sso_provider")).into_response();
}
};
let id_token = match token_json.get("id_token").and_then(|v| v.as_str()) {
Some(t) => t,
None => {
tracing::warn!("OIDC no id_token in response: {token_json}");
return Redirect::new(fail("sso_provider")).into_response();
}
};
// Decode JWT payload (no signature verification — token obtained directly from provider over TLS)
let claims = match decode_jwt_payload(id_token) {
Some(c) => c,
None => {
tracing::warn!("OIDC JWT decode failed");
return Redirect::new(fail("sso_provider")).into_response();
}
};
let preferred_username = match claims.get("preferred_username").and_then(|v| v.as_str()) {
Some(u) => u.to_string(),
None => {
tracing::warn!("OIDC no preferred_username in claims: {claims}");
return Redirect::new(fail("sso")).into_response();
}
};
let display_name = claims
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Check group membership
let allowed_groups = oidc_setting(&db, "oidc_allowed_groups").await?;
if !allowed_groups.trim().is_empty() {
let required: Vec<&str> = allowed_groups.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
let user_groups: Vec<String> = claims
.get("groups")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|g| g.as_str())
.map(|g| g.trim_start_matches('/').to_string())
.collect()
})
.unwrap_or_default();
let has_group = required.iter().any(|r| {
user_groups.iter().any(|ug| ug.eq_ignore_ascii_case(r))
});
if !has_group {
tracing::warn!(
"OIDC group check failed: user={preferred_username}, user_groups={user_groups:?}, required={required:?}"
);
return Redirect::new(fail("sso_group")).into_response();
}
}
// Find or create user
let login = preferred_username.clone();
let user = query!(User, $login == login).get(&db).await?;
let user = match user {
Some(u) => u,
None => {
let mut new_user = User {
id: Auto::auto(),
login: preferred_username.clone(),
password_hash: String::new(),
display_name: display_name.clone(),
telegram_chat_id: None,
telegram_notifications: Some(false),
status: "active".to_string(),
created_at: now_utc(),
updated_at: now_utc(),
};
new_user.save(&db).await?;
new_user
}
};
if user.status != "active" {
return Redirect::new(fail("sso_disabled")).into_response();
}
let display = user
.display_name
.as_deref()
.unwrap_or(&user.login)
.to_string();
session.insert(SESSION_USER_ID, user.id.unwrap()).await?;
session.insert(SESSION_USER_NAME, display).await?;
// Clear the oidc_state cookie
let clear_cookie = "oidc_state=; Path=/admin/oidc; HttpOnly; Secure; SameSite=Lax; Max-Age=0";
Redirect::new(format!("/admin/?lang={}", lang.code()))
.into_response()?
.with_header("set-cookie", clear_cookie)
.into_response()
}
// ---------------------------------------------------------------------------
// GET Handlers (protected)
// ---------------------------------------------------------------------------
@@ -738,12 +1066,24 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot:
Err(resp) => return Ok(resp),
};
let settings = Setting::objects().all(&db).await?;
let auth_password_checked = settings
.iter()
.find(|s| s.key == "auth_password_enabled")
.map(|s| s.value == "true")
.unwrap_or(true);
let auth_sso_checked = settings
.iter()
.find(|s| s.key == "auth_sso_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let body = SettingsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
settings,
saved: false,
auth_password_checked,
auth_sso_checked,
}
.render()?;
html_response(body, lang)
@@ -819,6 +1159,14 @@ struct SettingsForm {
seo_keywords: String,
turnstile_site_key: String,
turnstile_secret_key: String,
oidc_issuer_url: String,
oidc_client_id: String,
oidc_client_secret: String,
oidc_allowed_groups: String,
#[serde(default)]
auth_password_enabled: Option<String>,
#[serde(default)]
auth_sso_enabled: Option<String>,
}
async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result<Response> {
@@ -837,6 +1185,26 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
("seo_keywords", form.seo_keywords),
("turnstile_site_key", form.turnstile_site_key),
("turnstile_secret_key", form.turnstile_secret_key),
("oidc_issuer_url", form.oidc_issuer_url),
("oidc_client_id", form.oidc_client_id),
("oidc_client_secret", form.oidc_client_secret),
("oidc_allowed_groups", form.oidc_allowed_groups),
(
"auth_password_enabled",
if form.auth_password_enabled.is_some() {
"true".to_string()
} else {
"false".to_string()
},
),
(
"auth_sso_enabled",
if form.auth_sso_enabled.is_some() {
"true".to_string()
} else {
"false".to_string()
},
),
] {
let k = key.to_string();
let existing = query!(Setting, $key == k).get(&db).await?;
@@ -859,12 +1227,24 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot:
}
let settings = Setting::objects().all(&db).await?;
let auth_password_checked = settings
.iter()
.find(|s| s.key == "auth_password_enabled")
.map(|s| s.value == "true")
.unwrap_or(true);
let auth_sso_checked = settings
.iter()
.find(|s| s.key == "auth_sso_enabled")
.map(|s| s.value == "true")
.unwrap_or(false);
let rendered = SettingsTemplate {
t: lang.t(),
lang,
admin_name: &admin_name,
settings,
saved: true,
auth_password_checked,
auth_sso_checked,
}
.render()?;
html_response(rendered, lang)
@@ -2055,6 +2435,8 @@ pub fn admin_router() -> Router {
Route::with_handler_and_name("/logout", logout, "admin-logout"),
Route::with_handler_and_name("/setup", setup_page, "admin-setup"),
Route::with_handler_and_name("/setup/submit", setup_submit, "admin-setup-submit"),
Route::with_handler_and_name("/oidc/start", oidc_start, "admin-oidc-start"),
Route::with_handler_and_name("/oidc/callback", oidc_callback, "admin-oidc-callback"),
// Protected
Route::with_handler_and_name("", admin_index, "admin-index-bare"),
Route::with_handler_and_name("/", admin_index, "admin-index"),
+48
View File
@@ -137,6 +137,17 @@ pub struct Translations {
pub settings_seo_keywords: &'static str,
pub settings_turnstile_site_key: &'static str,
pub settings_turnstile_secret_key: &'static str,
pub settings_oidc_issuer_url: &'static str,
pub settings_oidc_client_id: &'static str,
pub settings_oidc_client_secret: &'static str,
pub settings_oidc_allowed_groups: &'static str,
pub settings_auth_password_enabled: &'static str,
pub settings_auth_sso_enabled: &'static str,
pub settings_section_advanced: &'static str,
pub settings_section_notifications: &'static str,
pub settings_section_captcha: &'static str,
pub settings_section_oidc: &'static str,
pub settings_section_general: &'static str,
pub landing_contact_label: &'static str,
pub landing_pricing_title: &'static str,
@@ -151,6 +162,11 @@ pub struct Translations {
pub login_title: &'static str,
pub login_button: &'static str,
pub login_error: &'static str,
pub login_sso_button: &'static str,
pub login_sso_error: &'static str,
pub login_sso_error_group: &'static str,
pub login_sso_error_provider: &'static str,
pub login_sso_error_user_disabled: &'static str,
pub logout: &'static str,
pub setup_title: &'static str,
pub setup_description: &'static str,
@@ -352,6 +368,17 @@ static RU: Translations = Translations {
settings_seo_keywords: "SEO-ключевые слова (через запятую, отображаются на сайте и в мета-теге keywords)",
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key (ключ виджета)",
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key (секретный ключ)",
settings_oidc_issuer_url: "OIDC — URL провайдера (Issuer URL)",
settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Разрешённые группы (через запятую, пусто = все)",
settings_auth_password_enabled: "Вход по логину и паролю",
settings_auth_sso_enabled: "Вход через SSO (OIDC)",
settings_section_advanced: "Расширенные настройки",
settings_section_notifications: "Уведомления",
settings_section_captcha: "Защита от ботов",
settings_section_oidc: "Единый вход (SSO / OIDC)",
settings_section_general: "Сайт",
landing_contact_label: "Или свяжитесь с нами напрямую",
landing_pricing_title: "Стоимость",
@@ -386,6 +413,11 @@ static RU: Translations = Translations {
login_title: "Вход в систему",
login_button: "Войти",
login_error: "Неверный логин или пароль.",
login_sso_button: "Войти через SSO",
login_sso_error: "Ошибка SSO-авторизации.",
login_sso_error_group: "У вас нет доступа: вы не состоите в разрешённой группе.",
login_sso_error_provider: "Не удалось связаться с провайдером авторизации.",
login_sso_error_user_disabled: "Ваша учётная запись отключена.",
logout: "Выйти",
setup_title: "Создание администратора",
setup_description: "В системе нет ни одного администратора. Создайте первого для начала работы.",
@@ -557,6 +589,17 @@ static EN: Translations = Translations {
settings_seo_keywords: "SEO keywords (comma-separated, shown on site and in keywords meta tag)",
settings_turnstile_site_key: "Cloudflare Turnstile — Site Key",
settings_turnstile_secret_key: "Cloudflare Turnstile — Secret Key",
settings_oidc_issuer_url: "OIDC — Issuer URL",
settings_oidc_client_id: "OIDC — Client ID",
settings_oidc_client_secret: "OIDC — Client Secret",
settings_oidc_allowed_groups: "OIDC — Allowed groups (comma-separated, empty = all)",
settings_auth_password_enabled: "Password login",
settings_auth_sso_enabled: "SSO login (OIDC)",
settings_section_advanced: "Advanced settings",
settings_section_notifications: "Notifications",
settings_section_captcha: "Bot protection",
settings_section_oidc: "Single Sign-On (SSO / OIDC)",
settings_section_general: "Site",
landing_contact_label: "Or contact us directly",
landing_pricing_title: "Pricing",
@@ -591,6 +634,11 @@ static EN: Translations = Translations {
login_title: "Sign In",
login_button: "Sign In",
login_error: "Invalid login or password.",
login_sso_button: "Sign in with SSO",
login_sso_error: "SSO authentication failed.",
login_sso_error_group: "Access denied: you are not a member of an allowed group.",
login_sso_error_provider: "Could not reach the authentication provider.",
login_sso_error_user_disabled: "Your account is disabled.",
logout: "Sign Out",
setup_title: "Create Administrator",
setup_description: "There are no administrators yet. Create the first one to get started.",
+3 -2
View File
@@ -11,8 +11,8 @@ use tracing_subscriber;
use cot::cli::CliMetadata;
use cot::config::{
DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig,
SessionStoreTypeConfig,
DatabaseConfig, MiddlewareConfig, ProjectConfig, SameSite, SessionMiddlewareConfig,
SessionStoreConfig, SessionStoreTypeConfig,
};
use cot::db::migrations::SyncDynMigration;
use cot::middleware::SessionMiddleware;
@@ -69,6 +69,7 @@ impl Project for PettingProject {
.session(
SessionMiddlewareConfig::builder()
.secure(false)
.same_site(SameSite::Lax)
.store(
SessionStoreConfig::builder()
.store_type(SessionStoreTypeConfig::Database)
+7 -1
View File
@@ -35,6 +35,11 @@
{% if let Some(err) = error.as_ref() %}
<div class="notification is-danger is-light">{{ err }}</div>
{% endif %}
{% if auth_sso_enabled %}
<a href="/admin/oidc/start" class="button is-primary is-fullwidth mt-3">{{ t.login_sso_button }}</a>
{% endif %}
{% if auth_password_enabled %}
{% if auth_sso_enabled %}<hr style="margin:1rem 0;">{% endif %}
<form method="post" action="/admin/login/submit">
<div class="field">
<label class="label">{{ t.users_login }}</label>
@@ -45,10 +50,11 @@
<div class="control"><input class="input" type="password" name="password" required></div>
</div>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-size="compact" style="margin-top:0.75rem;"></div>
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" style="margin-top:0.75rem;"></div>
{% endif %}
<button type="submit" class="button is-primary is-fullwidth mt-3">{{ t.login_button }}</button>
</form>
{% endif %}
</div>
</div>
</body>
+84 -32
View File
@@ -14,12 +14,8 @@
<div class="form-card">
<form method="post" action="/admin/settings/save">
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h2 class="subtitle is-5 mb-3" style="border-bottom:1px solid #eee;padding-bottom:0.5rem;">{{ t.settings_contact_info }}</h2>
<div class="field">
<label class="label">{{ t.settings_contact_info }}</label>
<div class="control">
@@ -32,18 +28,6 @@
<textarea class="input" name="pricing_info" rows="3" style="min-height:70px;resize:vertical;" placeholder="от 600 рублей за визит">{% for s in &settings %}{% if s.key == "pricing_info" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_site_domain }}</label>
<div class="control">
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_timezone }}</label>
<div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_seo_keywords }}</label>
<div class="control">
@@ -52,23 +36,91 @@
placeholder="зооняня Хабаровск, присмотр за питомцем Хабаровск, догситтер Хабаровск">{% for s in &settings %}{% if s.key == "seo_keywords" %}{{ s.value }}{% endif %}{% endfor %}</textarea>
</div>
<div id="seoPreview" style="margin-top:0.5rem;padding:0.5rem 0.75rem;background:#fafafa;border:1px solid #eee;border-radius:6px;min-height:2rem;line-height:2;font-size:0.85rem;display:none;"></div>
<p style="font-size:0.78rem;color:#aaa;margin-top:0.3rem;">Каждая фраза между запятыми — отдельное ключевое слово</p>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_site_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<details style="margin-top:1.5rem;">
<summary class="subtitle is-5 mb-3" style="cursor:pointer;border-bottom:1px solid #eee;padding-bottom:0.5rem;">
{{ t.settings_section_advanced }}
</summary>
<button type="submit" class="button is-primary">{{ t.settings_save }}</button>
<div style="margin-top:1rem;">
<h3 class="subtitle is-6 mb-2 has-text-grey">{{ t.settings_section_general }}</h3>
<div class="field">
<label class="label">{{ t.settings_site_domain }}</label>
<div class="control">
<input class="input" type="text" name="site_domain" placeholder="https://example.com" value="{% for s in &settings %}{% if s.key == "site_domain" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_timezone }}</label>
<div class="control">
<input class="input" type="text" name="timezone" placeholder="Asia/Vladivostok" value="{% for s in &settings %}{% if s.key == "timezone" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_notifications }}</h3>
<div class="field">
<label class="label">{{ t.settings_telegram_bot_token }}</label>
<div class="control">
<input class="input" type="text" name="telegram_bot_token" value="{% for s in &settings %}{% if s.key == "telegram_bot_token" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_captcha }}</h3>
<div class="field">
<label class="label">{{ t.settings_turnstile_site_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_site_key" value="{% for s in &settings %}{% if s.key == "turnstile_site_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_turnstile_secret_key }}</label>
<div class="control">
<input class="input" type="text" name="turnstile_secret_key" value="{% for s in &settings %}{% if s.key == "turnstile_secret_key" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<h3 class="subtitle is-6 mb-2 has-text-grey" style="margin-top:1.25rem;">{{ t.settings_section_oidc }}</h3>
<div class="field">
<label class="label">{{ t.settings_oidc_issuer_url }}</label>
<div class="control">
<input class="input" type="text" name="oidc_issuer_url" placeholder="https://keycloak.example.com/realms/myrealm" value="{% for s in &settings %}{% if s.key == "oidc_issuer_url" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_id }}</label>
<div class="control">
<input class="input" type="text" name="oidc_client_id" value="{% for s in &settings %}{% if s.key == "oidc_client_id" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_client_secret }}</label>
<div class="control">
<input class="input" type="password" name="oidc_client_secret" value="{% for s in &settings %}{% if s.key == "oidc_client_secret" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="label">{{ t.settings_oidc_allowed_groups }}</label>
<div class="control">
<input class="input" type="text" name="oidc_allowed_groups" placeholder="admins, web-petting" value="{% for s in &settings %}{% if s.key == "oidc_allowed_groups" %}{{ s.value }}{% endif %}{% endfor %}">
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_password_enabled" value="true"{% if auth_password_checked %} checked{% endif %}>
{{ t.settings_auth_password_enabled }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="auth_sso_enabled" value="true"{% if auth_sso_checked %} checked{% endif %}>
{{ t.settings_auth_sso_enabled }}
</label>
</div>
</div>
</details>
<button type="submit" class="button is-primary" style="margin-top:1.5rem;">{{ t.settings_save }}</button>
</form>
</div>