Implement UI toggle for Hide Local Login (related to issue #750)
This commit is contained in:
parent
8b7ff2ad48
commit
3acd29c3f3
7 changed files with 382 additions and 332 deletions
|
|
@ -82,6 +82,7 @@ Environment variables take precedence over `system.json`.
|
|||
| `LOG_LEVEL` | Log verbosity | `info` |
|
||||
| `DISCOVERY_ENABLED` | Auto-discover nodes | `false` |
|
||||
| `ALLOWED_ORIGINS` | CORS allowed domains | `""` (Same origin) |
|
||||
| `PULSE_AUTH_HIDE_LOCAL_LOGIN` | Hide username/password form (useful for SSO) | `false` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ Enable Single Sign-On (SSO) with providers like Authentik, Keycloak, Okta, and A
|
|||
* **Client ID & Secret**: From your IdP.
|
||||
4. **Save**: The login page will now show a "Continue with Single Sign-On" button.
|
||||
|
||||
> **Tip**: To hide the username/password form and only show the SSO button, set `PULSE_AUTH_HIDE_LOCAL_LOGIN=true` in your environment. You can still access the local login by appending `?show_local=true` to the login URL.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
| Setting | Description |
|
||||
|
|
|
|||
|
|
@ -638,10 +638,16 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
const [temperatureMonitoringEnabled, setTemperatureMonitoringEnabled] = createSignal(true);
|
||||
const [savingTemperatureSetting, setSavingTemperatureSetting] = createSignal(false);
|
||||
const [hostProxyStatus, setHostProxyStatus] = createSignal<HostProxyStatusResponse | null>(null);
|
||||
const [hideLocalLogin, setHideLocalLogin] = createSignal(false);
|
||||
const [savingHideLocalLogin, setSavingHideLocalLogin] = createSignal(false);
|
||||
|
||||
const temperatureMonitoringLocked = () =>
|
||||
Boolean(
|
||||
envOverrides().temperatureMonitoringEnabled || envOverrides()['ENABLE_TEMPERATURE_MONITORING'],
|
||||
);
|
||||
const hideLocalLoginLocked = () =>
|
||||
Boolean(envOverrides().hideLocalLogin || envOverrides()['PULSE_AUTH_HIDE_LOCAL_LOGIN']);
|
||||
|
||||
const pvePollingEnvLocked = () =>
|
||||
Boolean(envOverrides().pvePollingInterval || envOverrides().PVE_POLLING_INTERVAL);
|
||||
let discoverySubnetInputRef: HTMLInputElement | undefined;
|
||||
|
|
@ -705,6 +711,35 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleHideLocalLoginChange = async (enabled: boolean): Promise<void> => {
|
||||
if (hideLocalLoginLocked() || savingHideLocalLogin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = hideLocalLogin();
|
||||
setHideLocalLogin(enabled);
|
||||
setSavingHideLocalLogin(true);
|
||||
|
||||
try {
|
||||
await SettingsAPI.updateSystemSettings({ hideLocalLogin: enabled });
|
||||
if (enabled) {
|
||||
notificationStore.success('Local login hidden', 2000);
|
||||
} else {
|
||||
notificationStore.info('Local login visible', 2000);
|
||||
}
|
||||
// Reload security status to reflect changes
|
||||
await loadSecurityStatus();
|
||||
} catch (error) {
|
||||
logger.error('Failed to update hide local login setting', error);
|
||||
notificationStore.error(
|
||||
error instanceof Error ? error.message : 'Failed to update hide local login setting',
|
||||
);
|
||||
setHideLocalLogin(previous);
|
||||
} finally {
|
||||
setSavingHideLocalLogin(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applySavedDiscoverySubnet = (subnet?: string | null) => {
|
||||
const raw = typeof subnet === 'string' ? subnet.trim() : '';
|
||||
if (raw === '' || raw.toLowerCase() === 'auto') {
|
||||
|
|
@ -1960,6 +1995,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
? systemSettings.temperatureMonitoringEnabled
|
||||
: true,
|
||||
);
|
||||
// Load hideLocalLogin setting
|
||||
setHideLocalLogin(systemSettings.hideLocalLogin ?? false);
|
||||
|
||||
// Backup polling controls
|
||||
if (typeof systemSettings.backupPollingEnabled === 'boolean') {
|
||||
setBackupPollingEnabled(systemSettings.backupPollingEnabled);
|
||||
|
|
@ -2558,10 +2596,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
type="button"
|
||||
aria-current={isActive() ? 'page' : undefined}
|
||||
disabled={item.disabled}
|
||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${
|
||||
sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'
|
||||
} text-sm font-medium transition-colors ${
|
||||
item.disabled
|
||||
class={`flex w-full items-center ${sidebarCollapsed() ? 'justify-center' : 'gap-2.5'} rounded-md ${sidebarCollapsed() ? 'px-2 py-2.5' : 'px-3 py-2'
|
||||
} text-sm font-medium transition-colors ${item.disabled
|
||||
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600'
|
||||
: isActive()
|
||||
? 'bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-200'
|
||||
|
|
@ -2604,8 +2640,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
class={`px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||
disabled
|
||||
class={`px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap ${disabled
|
||||
? 'opacity-60 cursor-not-allowed text-gray-400 dark:text-gray-600 border-transparent'
|
||||
: isActive
|
||||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||||
|
|
@ -3596,8 +3631,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
pvePollingSelection() === option.value
|
||||
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${pvePollingSelection() === option.value
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
|
||||
} ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
|
|
@ -3615,8 +3649,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</For>
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
|
||||
pvePollingSelection() === 'custom'
|
||||
class={`rounded-lg border px-3 py-2 text-left text-sm transition-colors ${pvePollingSelection() === 'custom'
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 dark:border-blue-400 dark:bg-blue-900/30 dark:text-blue-100'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-blue-400 hover:text-blue-600 dark:border-gray-600 dark:bg-gray-900/50 dark:text-gray-200'
|
||||
} ${pvePollingEnvLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
|
|
@ -3792,8 +3825,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</legend>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
|
||||
discoveryMode() === 'auto'
|
||||
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${discoveryMode() === 'auto'
|
||||
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
|
||||
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
|
||||
}`}
|
||||
|
|
@ -3825,8 +3857,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</label>
|
||||
|
||||
<label
|
||||
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${
|
||||
discoveryMode() === 'custom'
|
||||
class={`flex items-start gap-3 rounded-lg border p-2 transition-colors ${discoveryMode() === 'custom'
|
||||
? 'border-blue-200 bg-blue-50/80 dark:border-blue-700 dark:bg-blue-900/20'
|
||||
: 'border-transparent hover:border-gray-200 dark:hover:border-gray-600'
|
||||
}`}
|
||||
|
|
@ -3869,8 +3900,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`rounded border px-2.5 py-1 text-[0.7rem] transition-colors ${
|
||||
isActive
|
||||
class={`rounded border px-2.5 py-1 text-[0.7rem] transition-colors ${isActive
|
||||
? 'border-blue-500 bg-blue-600 text-white dark:border-blue-400 dark:bg-blue-500'
|
||||
: 'border-gray-300 text-gray-700 hover:border-blue-400 hover:bg-blue-50 dark:border-gray-600 dark:text-gray-300 dark:hover:border-blue-500 dark:hover:bg-blue-900/30'
|
||||
}`}
|
||||
|
|
@ -3958,8 +3988,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
? 'auto (scan every network phase)'
|
||||
: '192.168.1.0/24, 10.0.0.0/24'
|
||||
}
|
||||
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
envOverrides().discoverySubnet
|
||||
class={`w-full rounded-lg border px-3 py-2 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 ${envOverrides().discoverySubnet
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-200 cursor-not-allowed opacity-60'
|
||||
: 'border-gray-300 bg-white dark:border-gray-600 dark:bg-gray-900/70'
|
||||
}`}
|
||||
|
|
@ -4084,8 +4113,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
}}
|
||||
disabled={envOverrides().allowedOrigins}
|
||||
placeholder="* or https://example.com"
|
||||
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${
|
||||
envOverrides().allowedOrigins
|
||||
class={`w-full px-3 py-1.5 text-sm border rounded-lg ${envOverrides().allowedOrigins
|
||||
? 'border-amber-300 dark:border-amber-600 bg-amber-50 dark:bg-amber-900/20 cursor-not-allowed opacity-75'
|
||||
: 'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800'
|
||||
}`}
|
||||
|
|
@ -4305,8 +4333,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
versionInfo()?.isDocker ||
|
||||
versionInfo()?.isSourceBuild
|
||||
}
|
||||
class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${
|
||||
versionInfo()?.isDocker || versionInfo()?.isSourceBuild
|
||||
class={`px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-2 ${versionInfo()?.isDocker || versionInfo()?.isSourceBuild
|
||||
? 'bg-gray-100 dark:bg-gray-700 text-gray-400 dark:text-gray-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
|
|
@ -5125,6 +5152,18 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Toggle
|
||||
label="Hide local login form"
|
||||
description="Hide the username/password form on the login page. Users will only see SSO options unless ?show_local=true is used."
|
||||
checked={hideLocalLogin()}
|
||||
onChange={(e: ToggleChangeEvent) => handleHideLocalLoginChange(e.target.checked)}
|
||||
disabled={hideLocalLoginLocked() || savingHideLocalLogin()}
|
||||
locked={hideLocalLoginLocked()}
|
||||
lockedMessage="This setting is managed by the PULSE_AUTH_HIDE_LOCAL_LOGIN environment variable"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Show when={!authDisabledByEnv() && showQuickSecurityWizard()}>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<QuickSecuritySetup
|
||||
|
|
@ -5503,8 +5542,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<div class="flex items-center justify-between">
|
||||
<span>Proxy socket</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
temp().socketFound ? 'bg-green-500' : 'bg-red-500'
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${temp().socketFound ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{temp().socketFound ? 'Detected' : 'Missing'}
|
||||
|
|
@ -5513,8 +5551,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<div class="flex items-center justify-between">
|
||||
<span>Daemon</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500'
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${temp().proxyReachable ? 'bg-green-500' : 'bg-yellow-500'
|
||||
}`}
|
||||
>
|
||||
{temp().proxyReachable ? 'Responding' : 'No response'}
|
||||
|
|
@ -5564,8 +5601,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
Control plane sync
|
||||
</div>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
temp().controlPlaneEnabled ? 'bg-green-500' : 'bg-gray-500'
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${temp().controlPlaneEnabled ? 'bg-green-500' : 'bg-gray-500'
|
||||
}`}
|
||||
>
|
||||
{temp().controlPlaneEnabled ? 'Enabled' : 'Disabled'}
|
||||
|
|
@ -5630,8 +5666,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
</Show>
|
||||
</div>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
proxy.reachable ? 'bg-green-500' : 'bg-red-500'
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${proxy.reachable ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{proxy.reachable ? 'Healthy' : 'Error'}
|
||||
|
|
@ -5848,8 +5883,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<div class="text-xs space-y-2 text-gray-600 dark:text-gray-400">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
apiDiag().enabled
|
||||
class={`px-2 py-0.5 rounded text-xs ${apiDiag().enabled
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
}`}
|
||||
|
|
@ -6005,8 +6039,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{entry.name}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
entry.status === 'online'
|
||||
class={`px-2 py-0.5 rounded text-xs ${entry.status === 'online'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-700/40 dark:text-red-100'
|
||||
}`}
|
||||
|
|
@ -6128,8 +6161,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
alerts().legacyThresholdsDetected
|
||||
class={`px-2 py-0.5 rounded text-xs ${alerts().legacyThresholdsDetected
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
}`}
|
||||
|
|
@ -6140,8 +6172,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
: 'migrated'}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
alerts().missingCooldown
|
||||
class={`px-2 py-0.5 rounded text-xs ${alerts().missingCooldown
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
}`}
|
||||
|
|
@ -6150,8 +6181,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{alerts().missingCooldown ? 'missing' : 'configured'}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-xs ${
|
||||
alerts().missingGroupingWindow
|
||||
class={`px-2 py-0.5 rounded text-xs ${alerts().missingGroupingWindow
|
||||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/40 dark:text-yellow-100'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-700/40 dark:text-green-100'
|
||||
}`}
|
||||
|
|
@ -6210,8 +6240,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{node.name}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
node.connected ? 'bg-green-500' : 'bg-red-500'
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${node.connected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{node.connected ? 'Connected' : 'Failed'}
|
||||
|
|
@ -6248,8 +6277,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
|||
{pbs.name}
|
||||
</span>
|
||||
<span
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${
|
||||
pbs.connected ? 'bg-green-500' : 'bg-red-500'
|
||||
class={`px-2 py-0.5 rounded text-white text-xs ${pbs.connected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
{pbs.connected ? 'Connected' : 'Failed'}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export interface SystemConfig {
|
|||
allowEmbedding?: boolean; // Allow iframe embedding
|
||||
allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding
|
||||
webhookAllowedPrivateCIDRs?: string; // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
|
||||
hideLocalLogin?: boolean; // Hide local login form (username/password)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -121,6 +122,7 @@ export interface SecurityStatus {
|
|||
disabled?: boolean; // legacy field (removed backend support)
|
||||
deprecatedDisableAuth?: boolean;
|
||||
message?: string;
|
||||
hideLocalLogin?: boolean; // Hide local login form
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2852,8 +2852,22 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
|
|||
backupEnabled := h.config.EnableBackupPolling
|
||||
settings.BackupPollingEnabled = &backupEnabled
|
||||
|
||||
// Create response structure that includes environment overrides
|
||||
response := struct {
|
||||
config.SystemSettings
|
||||
EnvOverrides map[string]bool `json:"envOverrides,omitempty"`
|
||||
}{
|
||||
SystemSettings: settings,
|
||||
EnvOverrides: make(map[string]bool),
|
||||
}
|
||||
|
||||
// Check for environment variable overrides
|
||||
if os.Getenv("PULSE_AUTH_HIDE_LOCAL_LOGIN") != "" {
|
||||
response.EnvOverrides["hideLocalLogin"] = true
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(settings)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleVerifyTemperatureSSH tests SSH connectivity to nodes for temperature monitoring
|
||||
|
|
|
|||
|
|
@ -654,6 +654,8 @@ func Load() (*Config, error) {
|
|||
} else {
|
||||
cfg.SSHPort = 22 // Default SSH port
|
||||
}
|
||||
// Load HideLocalLogin
|
||||
cfg.HideLocalLogin = systemSettings.HideLocalLogin
|
||||
// APIToken no longer loaded from system.json - only from .env
|
||||
log.Info().
|
||||
Str("updateChannel", cfg.UpdateChannel).
|
||||
|
|
|
|||
|
|
@ -831,6 +831,7 @@ type SystemSettings struct {
|
|||
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
|
||||
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
|
||||
WebhookAllowedPrivateCIDRs string `json:"webhookAllowedPrivateCIDRs,omitempty"` // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
|
||||
HideLocalLogin bool `json:"hideLocalLogin"` // Hide local login form (username/password)
|
||||
// APIToken removed - now handled via .env file only
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue