fix: auth, container resilience, ISO build, gamepad polish

- fix: login disconnect — verify session before WebSocket connect
- fix: 403 on app install — distinguish CSRF vs RBAC errors, only retry CSRF
- fix: health monitor now watches ALL containers (removed skip list for
  backend services like nbxplorer, databases, UI containers)
- fix: server.get-state added to CSRF-exempt list (read-only)
- fix: ISO build includes container-specs.sh and lib/common.sh in rootfs
  so reconcile actually works on fresh installs
- fix: gamepad nav — improved Server tab zone nav, focus styles, autofocus
- chore: move L484 web-only apps to Services tab
- chore: install store for cross-view install tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-03-30 13:35:02 +01:00
parent 68f2a9c5bf
commit fdd69ce1b5
16 changed files with 218 additions and 88 deletions

View File

@ -220,7 +220,8 @@ impl RpcHandler {
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!(rpc_req.method.as_str(),
"node-messages-received" | "server.echo" | "system.stats" | "tor.status"
"node-messages-received" | "server.echo" | "server.get-state"
| "system.stats" | "tor.status"
| "tor.onion-addresses" | "federation.list-nodes" | "system.get-settings"
| "system.get-node-key" | "system.get-metrics" | "system.get-version"
);

View File

@ -326,13 +326,9 @@ async fn check_containers() -> Vec<ContainerHealth> {
let containers: Vec<serde_json::Value> =
serde_json::from_str(&stdout).unwrap_or_default();
// Backend services and one-shot init containers to skip
let skip = [
"btcpay-db", "nbxplorer", "mempool-db", "mempool-api",
"penpot-postgres", "penpot-backend", "penpot-exporter", "penpot-valkey",
"penpot-mailcatch", "immich_postgres", "immich_redis",
"endurain-db", "nextcloud-db",
];
// Monitor ALL long-running containers for health — backend services (databases,
// nbxplorer, mempool-api) and UI containers need auto-restart too.
// Only skip ephemeral containers (build infrastructure, init one-shots).
containers
.iter()
@ -345,20 +341,16 @@ async fn check_containers() -> Vec<ContainerHealth> {
}
})?;
let app_id = name
.strip_prefix("archy-")
.unwrap_or(&name)
.to_string();
if skip.contains(&app_id.as_str()) || app_id.ends_with("-ui") {
return None;
}
// Skip podman-compose infrastructure and one-shot init containers
if name.starts_with("indeedhub-build_") || name.contains("-init") {
return None;
}
let app_id = name
.strip_prefix("archy-")
.unwrap_or(&name)
.to_string();
let state = c.get("State")
.and_then(|v| v.as_str())
.unwrap_or("unknown")

View File

@ -359,28 +359,31 @@ mod health_monitor_logic {
}
#[test]
fn ui_containers_skipped() {
fn all_long_running_containers_monitored() {
// Health monitor now checks ALL containers except ephemeral build/init ones.
// Backend services and UI containers are monitored for auto-restart.
let containers = vec![
("bitcoin-knots", "exited"),
("archy-bitcoin-ui", "exited"),
("archy-lnd-ui", "exited"),
("grafana", "exited"),
("nbxplorer", "exited"),
("indeedhub-build_api_1", "exited"),
("btcpay-init", "exited"),
];
let skip_suffixes = ["-ui"];
let skip_backends = ["btcpay-db", "nbxplorer", "mempool-db", "mempool-api"];
let to_check: Vec<&str> = containers
.iter()
.filter(|(name, _)| {
let id = name.strip_prefix("archy-").unwrap_or(name);
!skip_suffixes.iter().any(|s| id.ends_with(s))
&& !skip_backends.contains(&id)
!name.starts_with("indeedhub-build_") && !name.contains("-init")
})
.map(|(name, _)| *name)
.collect();
assert_eq!(to_check, vec!["bitcoin-knots", "grafana"]);
assert_eq!(to_check, vec![
"bitcoin-knots", "archy-bitcoin-ui", "archy-lnd-ui",
"grafana", "nbxplorer",
]);
}
#[test]

View File

@ -343,11 +343,13 @@ COPY archipelago-tor-helper.service /etc/systemd/system/archipelago-tor-helper.s
COPY archipelago-tor-helper.path /etc/systemd/system/archipelago-tor-helper.path
# Copy container doctor + reconcile scripts (referenced by the services above)
RUN mkdir -p /home/archipelago/archy/scripts
RUN mkdir -p /home/archipelago/archy/scripts/lib
COPY container-doctor.sh /home/archipelago/archy/scripts/container-doctor.sh
COPY reconcile-containers.sh /home/archipelago/archy/scripts/reconcile-containers.sh
COPY container-specs.sh /home/archipelago/archy/scripts/container-specs.sh
COPY tor-helper.sh /opt/archipelago/scripts/tor-helper.sh
RUN chmod +x /home/archipelago/archy/scripts/*.sh /opt/archipelago/scripts/*.sh && \
COPY lib/ /home/archipelago/archy/scripts/lib/
RUN chmod +x /home/archipelago/archy/scripts/*.sh /home/archipelago/archy/scripts/lib/*.sh /opt/archipelago/scripts/*.sh && \
chown -R archipelago:archipelago /home/archipelago/archy
# Enable services
@ -428,11 +430,16 @@ NGINXCONF
cp "$SCRIPT_DIR/configs/archipelago-reconcile.service" "$WORK_DIR/archipelago-reconcile.service"
cp "$SCRIPT_DIR/configs/archipelago-reconcile.timer" "$WORK_DIR/archipelago-reconcile.timer"
# Copy the actual scripts the services reference
for s in container-doctor.sh reconcile-containers.sh tor-helper.sh; do
for s in container-doctor.sh reconcile-containers.sh container-specs.sh tor-helper.sh; do
if [ -f "$SCRIPT_DIR/../scripts/$s" ]; then
cp "$SCRIPT_DIR/../scripts/$s" "$WORK_DIR/$s"
fi
done
# Copy shared script library (mem_limit etc.)
if [ -d "$SCRIPT_DIR/../scripts/lib" ]; then
mkdir -p "$WORK_DIR/lib"
cp "$SCRIPT_DIR/../scripts/lib/"*.sh "$WORK_DIR/lib/" 2>/dev/null || true
fi
echo " Using container doctor + reconcile timers from configs/"
fi

View File

@ -78,11 +78,22 @@ class RPCClient {
}
throw new Error('Session expired')
}
// CSRF 403: retry twice after delay (cookie may have been
// updated by a concurrent Set-Cookie response not yet visible to JS)
if (response.status === 403 && attempt < maxRetries - 1) {
await new Promise((r) => setTimeout(r, 500))
continue
// 403: read body to distinguish CSRF (retryable) from RBAC (permanent)
if (response.status === 403) {
let reason = ''
try {
const body: RPCResponse<unknown> = await response.json()
reason = body.error?.message || ''
} catch { /* body parse failed */ }
const isCsrf = !reason || reason.toLowerCase().includes('csrf')
if (isCsrf && attempt < maxRetries - 1) {
// CSRF mismatch — cookie may have been updated by a concurrent
// Set-Cookie response not yet visible to JS. Retry after delay.
await new Promise((r) => setTimeout(r, 500))
continue
}
throw new Error(reason || `HTTP 403: Forbidden`)
}
const err = new Error(`HTTP ${response.status}: ${response.statusText}`)
const isRetryable = response.status === 502 || response.status === 503

View File

@ -436,6 +436,9 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
// No containers on this page (e.g. Settings) — focus first focusable element
const z = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (z) { const f = getFocusableElements(z); if (f[0]) focusEl(f[0]) }
}
}, 100)
}
@ -475,16 +478,30 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
}
if (dir === 'down') {
// Down from nav bar → first container
// Down from nav bar → jump to containers (remember tab for Up return)
rememberFocus('navBar', activeEl)
const containers = getContainers()
const nearest = findNearestInDirection(activeEl, containers, 'down')
if (nearest) { rememberFocus('main', nearest); focusEl(nearest); return }
// Fallback: just focus first container
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]) }
if (containers[0]) { rememberFocus('main', containers[0]); focusEl(containers[0]); return }
// Containers not rendered yet — poll until they appear
let attempts = 0
const poll = setInterval(() => {
attempts++
const retryContainers = getContainers()
if (retryContainers[0]) {
clearInterval(poll)
rememberFocus('main', retryContainers[0])
focusEl(retryContainers[0])
} else if (attempts >= 10) {
clearInterval(poll)
}
}, 100)
return
}
// Up from nav bar → nothing
// Up from nav bar → nothing (use Escape to go to sidebar)
return
}
@ -500,15 +517,26 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return
}
// Up from top-row container → nav bar (if exists)
// Up from top-row container → nav bar, or previous focusable (linear pages like Settings)
if (dir === 'up') {
const remembered = recallFocus('navBar')
if (remembered) { focusEl(remembered); return }
const navItems = getNavBarItems()
if (navItems.length) {
const nearest = findNearestInDirection(activeEl, navItems, 'up')
if (nearest) { focusEl(nearest); return }
// Fallback: first nav bar item
const first = navItems[0]
if (first) focusEl(first)
if (first) { focusEl(first); return }
}
// No nav bar items — try any focusable element above (linear page nav)
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const above = findNearestInDirection(activeEl, allFocusable, 'up')
if (above) { rememberFocus('main', above); focusEl(above) }
}
return
}
@ -524,12 +552,15 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
return
}
// At grid edges: try all focusable elements in main zone as fallback
// (prevents dead ends when spatial nav between containers fails)
// At grid edges: try containers + nav bar items as fallback
// (prevents dead ends, but never jumps into container inner controls)
if (dir === 'down' || dir === 'right') {
const zone = document.querySelector('[data-controller-zone="main"]') as HTMLElement | null
if (zone) {
const allFocusable = getFocusableElements(zone)
const allFocusable = getFocusableElements(zone).filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const fallback = findNearestInDirection(activeEl, allFocusable, dir)
if (fallback) {
rememberFocus('main', fallback)
@ -550,7 +581,11 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
const target = activeTab ?? sidebar[0]
if (target) { rememberFocus('main', activeEl); focusEl(target) }
} else {
const all = getFocusableElements()
// Exclude container inner buttons to prevent focus getting lost
const all = getFocusableElements().filter(el =>
el.hasAttribute('data-controller-container') ||
!el.closest('[data-controller-container]')
)
const next = findNearestInDirection(activeEl, all, dir)
if (next) focusEl(next)
}
@ -607,6 +642,7 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
watch(() => route.path, () => {
zoneFocusMemory.delete('main')
zoneFocusMemory.delete('navBar')
setTimeout(autoFocusMain, 150)
})

View File

@ -32,7 +32,15 @@ export const useAuthStore = defineStore('auth', () => {
// Initialize data structure immediately so dashboard can render
await sync.initializeData()
// Connect WebSocket in background - don't block login flow
// Verify session cookies are established before WebSocket connect.
// Without this, the WS upgrade can race ahead of cookie processing → 401.
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } })
} catch {
// Non-fatal: WS reconnect logic will handle it
}
// Connect WebSocket in background
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after login, will retry:', err)
})
@ -52,6 +60,14 @@ export const useAuthStore = defineStore('auth', () => {
const sync = useSyncStore()
await sync.initializeData()
// Verify session cookies are established before WebSocket connect
try {
await rpcClient.call({ method: 'server.echo', params: { message: 'session-ready' } })
} catch {
// Non-fatal: WS reconnect logic will handle it
}
sync.connectWebSocket().catch((err) => {
if (import.meta.env.DEV) console.warn('[Store] WebSocket connection failed after TOTP login, will retry:', err)
})

View File

@ -0,0 +1,82 @@
// Install store — tracks in-progress app installations across navigation.
// Marketplace.vue writes here; Apps.vue reads to show "Installing..." cards.
import { defineStore } from 'pinia'
import { reactive, computed } from 'vue'
export interface InstallEntry {
id: string
title: string
status: 'downloading' | 'installing' | 'starting' | 'complete' | 'error'
progress: number
message: string
}
export const useInstallStore = defineStore('install', () => {
// Reactive map: appId -> InstallEntry
const entries = reactive(new Map<string, InstallEntry>())
/** All app IDs currently installing */
const installingIds = computed(() => new Set(entries.keys()))
/** Start tracking an install */
function trackInstall(id: string, title: string) {
entries.set(id, {
id,
title,
status: 'downloading',
progress: 0,
message: 'Preparing installation...',
})
}
/** Update progress for an in-flight install */
function updateProgress(id: string, update: Partial<Omit<InstallEntry, 'id'>>) {
const current = entries.get(id)
if (!current) return
entries.set(id, { ...current, ...update })
}
/** Mark install complete and auto-clear after delay */
function completeInstall(id: string) {
const current = entries.get(id)
if (!current) return
entries.set(id, { ...current, status: 'complete', progress: 100, message: 'Installation complete!' })
setTimeout(() => entries.delete(id), 2000)
}
/** Mark install as failed and auto-clear after delay */
function failInstall(id: string, message: string) {
const current = entries.get(id)
if (!current) return
entries.set(id, { ...current, status: 'error', progress: 0, message })
setTimeout(() => entries.delete(id), 5000)
}
/** Remove tracking (e.g. when backend reports the app is installed) */
function clearInstall(id: string) {
entries.delete(id)
}
/** Check if an app is currently installing */
function isInstalling(id: string): boolean {
return entries.has(id)
}
/** Get progress for an app, or undefined */
function getProgress(id: string): InstallEntry | undefined {
return entries.get(id)
}
return {
entries,
installingIds,
trackInstall,
updateProgress,
completeInstall,
failInstall,
clearInstall,
isInstalling,
getProgress,
}
})

View File

@ -53,7 +53,6 @@
*:focus-visible {
outline: none;
box-shadow:
0 0 0 1px rgba(251, 146, 60, 0.4),
0 0 12px rgba(251, 146, 60, 0.2),
0 0 24px rgba(251, 146, 60, 0.08);
transition: box-shadow 0.2s ease;
@ -68,7 +67,6 @@ input:focus-visible,
textarea:focus-visible,
select:focus-visible {
box-shadow: unset;
border-color: rgba(251, 146, 60, 0.4);
outline: none;
}
@ -124,18 +122,21 @@ input[type="radio"]:active + * {
/* Containers: base scale for smooth grow animation */
[data-controller-container] {
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
outline: none !important;
}
/* Containers: console-style focus — subtle lift + ambient glow through glass */
[data-controller-container]:focus-visible {
/* Containers: console-style focus lift + ambient orange glow.
Pure glow approach no border-color or outline changes, avoids
Chromium compositor bugs with border-radius on translateZ(0) layers. */
[data-controller-container]:focus-visible,
[data-controller-container]:focus {
outline: none;
transform: scale(1.01) translateZ(0);
transform: translateY(-4px) scale(1.01) translateZ(0);
box-shadow:
0 0 0 1px rgba(251, 146, 60, 0.35),
0 4px 20px rgba(251, 146, 60, 0.12),
0 0 40px rgba(251, 146, 60, 0.06),
inset 0 1px 0 rgba(251, 146, 60, 0.1);
0 0 6px 2px rgba(251, 146, 60, 0.35),
0 0 20px rgba(251, 146, 60, 0.15),
0 0 40px rgba(251, 146, 60, 0.08);
}
/* Global glassmorphism utilities */
@ -2159,19 +2160,6 @@ html:has(body.video-background-active)::before {
font-size: 0.8125rem;
}
.discover-featured-card {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease, border-color 0.3s ease;
border-color: rgba(255, 255, 255, 0.12);
}
.discover-featured-card:hover {
transform: translateY(-3px);
border-color: rgba(251, 146, 60, 0.25);
box-shadow:
0 12px 32px rgba(0, 0, 0, 0.6),
0 0 40px rgba(251, 146, 60, 0.06);
}
.discover-installed-badge {
display: inline-flex;
align-items: center;
@ -2198,15 +2186,6 @@ html:has(body.video-background-active)::before {
transform: translateY(-2px);
}
.discover-app-card {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s ease;
}
.discover-app-card:hover {
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.06);
}
.discover-manifesto {
border-color: rgba(251, 146, 60, 0.1);
background: linear-gradient(135deg, rgba(0, 0, 0, 0.7) 0%, rgba(251, 146, 60, 0.03) 100%);

View File

@ -49,7 +49,7 @@
<!-- Overview Cards -->
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6 mb-8">
<!-- Local Network Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -129,7 +129,7 @@
</div>
<!-- Web3 Card -->
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col">
<div data-controller-container tabindex="0" class="glass-card p-6 flex flex-col transition-all hover:-translate-y-1">
<div class="flex items-start gap-4 mb-4 shrink-0">
<div class="flex-shrink-0 w-12 h-12 rounded-lg bg-white/10 flex items-center justify-center">
<svg class="w-6 h-6 text-white/80" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
@ -156,7 +156,7 @@
<div class="grid grid-cols-1 2xl:grid-cols-2 gap-6 mb-6">
<!-- Network Interfaces -->
<div class="glass-card p-6">
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-white mb-1">Network Interfaces</h2>

View File

@ -15,6 +15,9 @@ export const SERVICE_NAMES = new Set([
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
'indeedhub-build_postgres_1', 'indeedhub-build_redis_1', 'indeedhub-build_minio_1',
'indeedhub-build_minio-init_1', 'indeedhub-build_relay_1',
// L484 web-only apps — parked in Services for now
'botfights', 'nwnn', '484-kitchen', 'call-the-operator',
'syntropy-institute', 't-zero', 'arch-presentation',
])
export function isServiceContainer(id: string): boolean {

View File

@ -9,7 +9,7 @@
:data-controller-install="!(isInstalled(app.id) || installingApps.has(app.id)) && (app.source === 'local' || !!app.dockerImage) ? '1' : undefined"
tabindex="0"
role="link"
class="discover-app-card glass-card p-5 cursor-pointer flex flex-col"
class="glass-card p-5 transition-all hover:-translate-y-1 cursor-pointer flex flex-col"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index + staggerOffset }"
@click="$emit('view-details', app)"

View File

@ -13,7 +13,7 @@
data-controller-container
tabindex="0"
role="link"
class="discover-featured-card glass-card p-6 cursor-pointer"
class="glass-card p-6 transition-all hover:-translate-y-1 cursor-pointer"
:class="{ 'card-stagger': showStagger }"
:style="{ '--stagger-index': index }"
@click="$emit('view-details', app)"

View File

@ -1,8 +1,8 @@
<template>
<div class="glass-card p-6 mb-6">
<div data-controller-container tabindex="0" class="glass-card p-6 mb-6 transition-all hover:-translate-y-1">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Service Status -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="servicesRunning ? 'bg-green-400' : 'bg-red-400'"></div>
@ -23,7 +23,7 @@
</div>
<!-- Tor Status -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="relative shrink-0">
<div class="w-3 h-3 rounded-full" :class="torStatusColor"></div>
@ -44,7 +44,7 @@
</div>
<!-- Auto-Sync Toggle -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center justify-between min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -60,7 +60,7 @@
</div>
<!-- Logs & Diagnostics -->
<div data-controller-container tabindex="0" class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex flex-col gap-3 p-4 bg-white/5 rounded-lg min-w-0">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-white/60 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />

View File

@ -1,5 +1,5 @@
<template>
<div class="glass-card p-6">
<div data-controller-container tabindex="0" class="glass-card p-6 transition-all hover:-translate-y-1">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="relative shrink-0">

View File

@ -76,7 +76,7 @@ function closeChangePasswordModal() {
<template>
<!-- Change Password -->
<div data-controller-container tabindex="0" class="mb-6">
<div class="mb-6">
<button
ref="changePasswordRestoreFocusRef"
@click="showChangePasswordModal = true"