feat(settings): session timeout is configurable from the UI
Demo images / Build & push demo images (push) Successful in 3m43s
Demo images / Build & push demo images (push) Successful in 3m43s
auth.session-policy.get/set plus a card under Account. Presented as two plain questions rather than the token mechanism underneath, because the distinction that matters to an operator is which control actually ends a session: the dashboard polls constantly, so an idle timeout alone never fires on an open tab — the absolute cap is what guarantees it. Values are clamped server-side and the stored result is echoed back, so the bounds are discoverable instead of an error. Presets rather than a free number field: a box accepting '5' invites locking yourself out. A short idle choice warns that it is the payments-industry posture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
81033ed6f5
commit
9dd02359e4
@@ -472,6 +472,8 @@ impl RpcHandler {
|
||||
"system.disk-cleanup" => self.handle_system_disk_cleanup().await,
|
||||
"system.reboot" => self.handle_system_reboot(params).await,
|
||||
"system.factory-reset" => self.handle_system_factory_reset(params).await,
|
||||
"auth.session-policy.get" => self.handle_session_policy_get().await,
|
||||
"auth.session-policy.set" => self.handle_session_policy_set(params).await,
|
||||
"system.settings.get" => self.handle_system_settings_get(params).await,
|
||||
"system.settings.set" => self.handle_system_settings_set(params).await,
|
||||
"system.kiosk-display.get" => self.handle_system_kiosk_display_get().await,
|
||||
|
||||
@@ -1011,6 +1011,59 @@ impl RpcHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// auth.session-policy.get — how long a login lasts on this node.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_get(&self) -> Result<serde_json::Value> {
|
||||
let policy = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": policy.idle_timeout_secs,
|
||||
"absolute_timeout_secs": policy.absolute_timeout_secs,
|
||||
"reauth_for_funds": policy.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// auth.session-policy.set — change it.
|
||||
///
|
||||
/// Values are clamped rather than rejected: the caller learns what was
|
||||
/// actually stored from the reply, which is friendlier than an error and
|
||||
/// makes the bounds discoverable. Fields are individually optional so the
|
||||
/// UI can change one control without having to send the others back.
|
||||
pub(in crate::api::rpc) async fn handle_session_policy_set(
|
||||
&self,
|
||||
params: Option<serde_json::Value>,
|
||||
) -> Result<serde_json::Value> {
|
||||
let params = params.unwrap_or(serde_json::json!({}));
|
||||
let current = crate::settings::session_policy::load(&self.config.data_dir).await;
|
||||
let policy = crate::settings::session_policy::SessionPolicy {
|
||||
idle_timeout_secs: params
|
||||
.get("idle_timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(current.idle_timeout_secs),
|
||||
absolute_timeout_secs: match params.get("absolute_timeout_secs") {
|
||||
// Explicit null means "no absolute cap", which is different
|
||||
// from the field being absent (leave it as it is).
|
||||
Some(serde_json::Value::Null) => None,
|
||||
Some(v) => v.as_u64().or(current.absolute_timeout_secs),
|
||||
None => current.absolute_timeout_secs,
|
||||
},
|
||||
reauth_for_funds: params
|
||||
.get("reauth_for_funds")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(current.reauth_for_funds),
|
||||
};
|
||||
let saved = crate::settings::session_policy::save(&self.config.data_dir, policy).await?;
|
||||
tracing::info!(
|
||||
idle = saved.idle_timeout_secs,
|
||||
absolute = ?saved.absolute_timeout_secs,
|
||||
reauth_for_funds = saved.reauth_for_funds,
|
||||
"session policy updated"
|
||||
);
|
||||
Ok(serde_json::json!({
|
||||
"idle_timeout_secs": saved.idle_timeout_secs,
|
||||
"absolute_timeout_secs": saved.absolute_timeout_secs,
|
||||
"reauth_for_funds": saved.reauth_for_funds,
|
||||
}))
|
||||
}
|
||||
|
||||
/// system.settings.set — Write a settings value
|
||||
pub(in crate::api::rpc) async fn handle_system_settings_set(
|
||||
&self,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
|
||||
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
|
||||
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
|
||||
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
@@ -24,6 +25,7 @@ async function handleLogout() {
|
||||
<AccountInfoSection />
|
||||
<ChangePasswordSection />
|
||||
<TwoFactorSection />
|
||||
<SessionTimeoutSection />
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* How long a login lasts on this node.
|
||||
*
|
||||
* Presented as two plain questions — "sign me out after quiet" and "always
|
||||
* sign me out after" — rather than as the two-token mechanism underneath.
|
||||
* The distinction that matters to the operator is that the second one is
|
||||
* what actually guarantees a login ends: this dashboard polls constantly,
|
||||
* so an idle timeout alone never fires on an open tab.
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
type Policy = {
|
||||
idle_timeout_secs: number
|
||||
absolute_timeout_secs: number | null
|
||||
reauth_for_funds: boolean
|
||||
}
|
||||
|
||||
const idle = ref<number>(86400)
|
||||
const absolute = ref<number | null>(30 * 24 * 3600)
|
||||
const reauthForFunds = ref(true)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saved = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Offered as presets rather than a free number field: the useful values are
|
||||
// few, and a box accepting "5" invites someone to lock themselves out.
|
||||
const idleChoices = [
|
||||
{ label: '15 minutes', value: 15 * 60 },
|
||||
{ label: '1 hour', value: 3600 },
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
]
|
||||
const absoluteChoices = [
|
||||
{ label: '1 day', value: 86400 },
|
||||
{ label: '1 week', value: 7 * 24 * 3600 },
|
||||
{ label: '30 days', value: 30 * 24 * 3600 },
|
||||
{ label: '90 days', value: 90 * 24 * 3600 },
|
||||
{ label: 'Never', value: null },
|
||||
]
|
||||
|
||||
const shortIdleWarning = computed(() => idle.value <= 3600)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
error.value = ''
|
||||
try {
|
||||
// The backend clamps and returns what it stored, so reflect that back
|
||||
// rather than assuming our values were taken verbatim.
|
||||
const p = await rpcClient.call<Policy>({
|
||||
method: 'auth.session-policy.set',
|
||||
params: {
|
||||
idle_timeout_secs: idle.value,
|
||||
absolute_timeout_secs: absolute.value,
|
||||
reauth_for_funds: reauthForFunds.value,
|
||||
},
|
||||
})
|
||||
idle.value = p.idle_timeout_secs
|
||||
absolute.value = p.absolute_timeout_secs
|
||||
reauthForFunds.value = p.reauth_for_funds
|
||||
saved.value = true
|
||||
setTimeout(() => { saved.value = false }, 2500)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
|
||||
<p class="text-sm text-white/60 mb-4">
|
||||
How long this node keeps you signed in. TV and kiosk screens are never
|
||||
signed out for sitting idle — there is nobody there to sign them back in.
|
||||
</p>
|
||||
|
||||
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="!loading" class="space-y-4">
|
||||
<div>
|
||||
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
|
||||
<select
|
||||
id="idle-timeout"
|
||||
v-model.number="idle"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
|
||||
Short timeouts are what payment-industry rules ask for when funds are involved — expect to sign in often.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
|
||||
<select
|
||||
id="absolute-timeout"
|
||||
v-model="absolute"
|
||||
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
|
||||
>
|
||||
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
|
||||
</select>
|
||||
<p class="text-xs text-white/50 mt-2">
|
||||
Counts from when you signed in, whatever you are doing. This is the one that
|
||||
guarantees a session ends: an open dashboard is never idle, so the setting
|
||||
above would not fire on it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
|
||||
<span class="text-sm text-white/80">
|
||||
Ask for my password again before sending funds
|
||||
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
:disabled="saving"
|
||||
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
|
||||
@click="save"
|
||||
>
|
||||
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user