@ -32,6 +32,9 @@
let saving = $state(false);
let saving = $state(false);
let loadingPresets = $state(false);
let loadingPresets = $state(false);
let settingsLoaded = $state(false);
let settingsLoaded = $state(false);
let configStatus = $state< any > (null);
let loadingConfig = $state(false);
let expandedSections = $state< Set < string > >(new Set(['github', 'git']));
// Preset values that will be used if user doesn't override
// Preset values that will be used if user doesn't override
let presetUserName = $state('');
let presetUserName = $state('');
@ -167,6 +170,20 @@
}
}
}
}
async function loadConfigStatus() {
loadingConfig = true;
try {
const response = await fetch('/api/config');
if (response.ok) {
configStatus = await response.json();
}
} catch (err) {
console.error('Failed to load config status:', err);
} finally {
loadingConfig = false;
}
}
// Load settings and presets on mount
// Load settings and presets on mount
onMount(async () => {
onMount(async () => {
// Redirect to /settings/general if no tab is specified
// Redirect to /settings/general if no tab is specified
@ -177,6 +194,7 @@
await loadSettings();
await loadSettings();
await loadPresets();
await loadPresets();
await loadConfigStatus();
});
});
// Sync activeTab with URL param when it changes
// Sync activeTab with URL param when it changes
@ -355,6 +373,350 @@
<!-- Connections Tab -->
<!-- Connections Tab -->
{ #if activeTab === 'connections' }
{ #if activeTab === 'connections' }
<!-- Server Configuration Status -->
< div class = "setting-group" >
< h3 class = "setting-section-title" > Server Configuration< / h3 >
< p class = "setting-description" >
Environment variables and server settings. Configure these in your environment or process manager.
< / p >
{ #if loadingConfig }
< p class = "setting-description" > Loading configuration status...< / p >
{ :else if configStatus }
<!-- GitHub Configuration -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('github')) {
expandedSections.delete('github');
} else {
expandedSections.add('github');
}
expandedSections = expandedSections; // Trigger reactivity
}}
>
< span class = "section-title" > GitHub Integration< / span >
< span class = "section-toggle" > { expandedSections . has ( 'github' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'github' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > GITHUB_TOKEN:< / span >
< span class = "config-value" class:configured = { configStatus . github . tokenConfigured } >
{ configStatus . github . tokenConfigured ? '✓ Configured' : '✗ Not configured' }
< / span >
< / div >
< div class = "config-docs" >
< p > < strong > Purpose:< / strong > GitHub Personal Access Token for API authentication< / p >
< p > < strong > Why needed:< / strong > Without a token, you're limited to 60 requests/hour per IP. With a token, you get 5,000 requests/hour.< / p >
< p > < strong > How to set:< / strong > < code > export GITHUB_TOKEN=your_token_here< / code > < / p >
< p > < strong > How to create:< / strong > GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) → Generate new token (classic) with < code > public_repo< / code > scope< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Git Configuration -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('git')) {
expandedSections.delete('git');
} else {
expandedSections.add('git');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Git Configuration< / span >
< span class = "section-toggle" > { expandedSections . has ( 'git' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'git' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > GIT_REPO_ROOT:< / span >
< span class = "config-value" > { configStatus . git . repoRoot } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > GIT_DOMAIN:< / span >
< span class = "config-value" > { configStatus . git . domain } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > DEFAULT_BRANCH:< / span >
< span class = "config-value" > { configStatus . git . defaultBranch } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > GIT_OPERATION_TIMEOUT_MS:< / span >
< span class = "config-value" > { configStatus . git . operationTimeoutMs } ms ({ Math . round ( configStatus . git . operationTimeoutMs / 1000 / 60 )} min)</ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > GIT_CLONE_TIMEOUT_MS:< / span >
< span class = "config-value" > { configStatus . git . cloneTimeoutMs } ms ({ Math . round ( configStatus . git . cloneTimeoutMs / 1000 / 60 )} min)</ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > ALLOW_FORCE_PUSH:< / span >
< span class = "config-value" > { configStatus . git . allowForcePush ? '✓ Enabled' : '✗ Disabled' } </ span >
< / div >
< div class = "config-docs" >
< p > < strong > GIT_REPO_ROOT:< / strong > Directory where repositories are stored (default: < code > /repos< / code > )< / p >
< p > < strong > GIT_DOMAIN:< / strong > Domain for git clone URLs (default: < code > localhost:6543< / code > )< / p >
< p > < strong > DEFAULT_BRANCH:< / strong > Default branch name for new repositories (default: < code > master< / code > )< / p >
< p > < strong > GIT_OPERATION_TIMEOUT_MS:< / strong > Timeout for git operations in milliseconds (default: 300000 = 5 minutes)< / p >
< p > < strong > GIT_CLONE_TIMEOUT_MS:< / strong > Timeout for git clone operations (default: 300000 = 5 minutes)< / p >
< p > < strong > ALLOW_FORCE_PUSH:< / strong > Allow force push operations (default: < code > false< / code > )< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Nostr Configuration -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('nostr')) {
expandedSections.delete('nostr');
} else {
expandedSections.add('nostr');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Nostr Configuration< / span >
< span class = "section-toggle" > { expandedSections . has ( 'nostr' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'nostr' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > NOSTR_RELAYS:< / span >
< span class = "config-value" > { configStatus . nostr . relays . length } relay(s) configured</ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > NOSTR_SEARCH_RELAYS:< / span >
< span class = "config-value" > { configStatus . nostr . searchRelays . length > 0 ? configStatus . nostr . searchRelays . length + ' relay(s)' : 'Using defaults' } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > NIP98_AUTH_WINDOW_SECONDS:< / span >
< span class = "config-value" > { configStatus . nostr . nip98AuthWindowSeconds } s</ span >
< / div >
< div class = "config-docs" >
< p > < strong > NOSTR_RELAYS:< / strong > Comma-separated list of Nostr relays for publishing/fetching (default: < code > wss://theforest.nostr1.com,wss://nostr.land< / code > )< / p >
< p > < strong > NOSTR_SEARCH_RELAYS:< / strong > Comma-separated list of relays for searching (uses extended default list if not set)< / p >
< p > < strong > NIP98_AUTH_WINDOW_SECONDS:< / strong > Authentication window for NIP-98 HTTP auth (default: 60 seconds)< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Tor Configuration -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('tor')) {
expandedSections.delete('tor');
} else {
expandedSections.add('tor');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Tor Support< / span >
< span class = "section-toggle" > { expandedSections . has ( 'tor' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'tor' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > TOR_SOCKS_PROXY:< / span >
< span class = "config-value" > { configStatus . tor . enabled ? configStatus . tor . socksProxy : 'Disabled' } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > TOR_HOSTNAME_FILE:< / span >
< span class = "config-value" > { configStatus . tor . hostnameFile || 'Not set' } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > TOR_ONION_ADDRESS:< / span >
< span class = "config-value" > { configStatus . tor . onionAddress || 'Not set' } </ span >
< / div >
< div class = "config-docs" >
< p > < strong > TOR_SOCKS_PROXY:< / strong > Tor SOCKS proxy address (format: < code > host:port< / code > , default: < code > 127.0.0.1:9050< / code > , set to empty to disable)< / p >
< p > < strong > TOR_HOSTNAME_FILE:< / strong > Path to file containing Tor hidden service hostname< / p >
< p > < strong > TOR_ONION_ADDRESS:< / strong > Tor .onion address for the service< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Security Configuration -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('security')) {
expandedSections.delete('security');
} else {
expandedSections.add('security');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Security Settings< / span >
< span class = "section-toggle" > { expandedSections . has ( 'security' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'security' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > ADMIN_PUBKEYS:< / span >
< span class = "config-value" class:configured = { configStatus . security . adminPubkeysConfigured } >
{ configStatus . security . adminPubkeysConfigured ? '✓ Configured' : '✗ Not configured' }
< / span >
< / div >
< div class = "config-item" >
< span class = "config-label" > AUDIT_LOGGING_ENABLED:< / span >
< span class = "config-value" > { configStatus . security . auditLoggingEnabled ? '✓ Enabled' : '✗ Disabled' } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > AUDIT_LOG_FILE:< / span >
< span class = "config-value" > { configStatus . security . auditLogFile || 'Default location' } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > AUDIT_LOG_RETENTION_DAYS:< / span >
< span class = "config-value" > { configStatus . security . auditLogRetentionDays } days</ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > RATE_LIMIT_ENABLED:< / span >
< span class = "config-value" > { configStatus . security . rateLimitEnabled ? '✓ Enabled' : '✗ Disabled' } </ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > RATE_LIMIT_WINDOW_MS:< / span >
< span class = "config-value" > { configStatus . security . rateLimitWindowMs } ms ({ Math . round ( configStatus . security . rateLimitWindowMs / 1000 )} s)</ span >
< / div >
< div class = "config-docs" >
< p > < strong > ADMIN_PUBKEYS:< / strong > Comma-separated list of admin pubkeys (hex format) with elevated privileges< / p >
< p > < strong > AUDIT_LOGGING_ENABLED:< / strong > Enable audit logging (default: < code > true< / code > , set to < code > false< / code > to disable)< / p >
< p > < strong > AUDIT_LOG_FILE:< / strong > Path to audit log file (uses default if not set)< / p >
< p > < strong > AUDIT_LOG_RETENTION_DAYS:< / strong > Number of days to retain audit logs (default: 90)< / p >
< p > < strong > RATE_LIMIT_ENABLED:< / strong > Enable rate limiting (default: < code > true< / code > , set to < code > false< / code > to disable)< / p >
< p > < strong > RATE_LIMIT_WINDOW_MS:< / strong > Rate limit window in milliseconds (default: 60000 = 1 minute)< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Resource Limits -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('resources')) {
expandedSections.delete('resources');
} else {
expandedSections.add('resources');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Resource Limits< / span >
< span class = "section-toggle" > { expandedSections . has ( 'resources' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'resources' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > MAX_REPOS_PER_USER:< / span >
< span class = "config-value" > { configStatus . resources . maxReposPerUser } repositories</ span >
< / div >
< div class = "config-item" >
< span class = "config-label" > MAX_DISK_QUOTA_PER_USER:< / span >
< span class = "config-value" > { Math . round ( configStatus . resources . maxDiskQuotaPerUser / 1024 / 1024 / 1024 )} GB ({ configStatus . resources . maxDiskQuotaPerUser } bytes)</ span >
< / div >
< div class = "config-docs" >
< p > < strong > MAX_REPOS_PER_USER:< / strong > Maximum number of repositories per user (default: 100)< / p >
< p > < strong > MAX_DISK_QUOTA_PER_USER:< / strong > Maximum disk quota per user in bytes (default: 10737418240 = 10 GB)< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Messaging Configuration -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('messaging')) {
expandedSections.delete('messaging');
} else {
expandedSections.add('messaging');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Messaging Configuration< / span >
< span class = "section-toggle" > { expandedSections . has ( 'messaging' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'messaging' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > MESSAGING_PREFS_ENCRYPTION_KEY:< / span >
< span class = "config-value" class:configured = { configStatus . messaging . encryptionKeyConfigured } >
{ configStatus . messaging . encryptionKeyConfigured ? '✓ Configured' : '✗ Not configured' }
< / span >
< / div >
< div class = "config-item" >
< span class = "config-label" > MESSAGING_SALT_ENCRYPTION_KEY:< / span >
< span class = "config-value" class:configured = { configStatus . messaging . saltEncryptionKeyConfigured } >
{ configStatus . messaging . saltEncryptionKeyConfigured ? '✓ Configured' : '✗ Not configured' }
< / span >
< / div >
< div class = "config-item" >
< span class = "config-label" > MESSAGING_LOOKUP_SECRET:< / span >
< span class = "config-value" class:configured = { configStatus . messaging . lookupSecretConfigured } >
{ configStatus . messaging . lookupSecretConfigured ? '✓ Configured' : '✗ Not configured' }
< / span >
< / div >
< div class = "config-docs" >
< p > < strong > MESSAGING_PREFS_ENCRYPTION_KEY:< / strong > Encryption key for messaging preferences< / p >
< p > < strong > MESSAGING_SALT_ENCRYPTION_KEY:< / strong > Encryption key for salt values< / p >
< p > < strong > MESSAGING_LOOKUP_SECRET:< / strong > Secret for message lookup operations< / p >
< / div >
< / div >
{ /if }
< / div >
<!-- Enterprise Mode -->
< div class = "config-section" >
< button
class="config-section-header"
onclick={() => {
if (expandedSections.has('enterprise')) {
expandedSections.delete('enterprise');
} else {
expandedSections.add('enterprise');
}
expandedSections = expandedSections;
}}
>
< span class = "section-title" > Enterprise Mode< / span >
< span class = "section-toggle" > { expandedSections . has ( 'enterprise' ) ? '▼' : '▶' } </ span >
< / button >
{ #if expandedSections . has ( 'enterprise' )}
< div class = "config-status" >
< div class = "config-item" >
< span class = "config-label" > ENTERPRISE_MODE:< / span >
< span class = "config-value" > { configStatus . enterprise . enabled ? '✓ Enabled' : '✗ Disabled (Lightweight mode)' } </ span >
< / div >
< div class = "config-docs" >
< p > < strong > ENTERPRISE_MODE:< / strong > Enable enterprise mode for Kubernetes container-per-tenant architecture (default: < code > false< / code > , set to < code > true< / code > to enable)< / p >
< / div >
< / div >
{ /if }
< / div >
{ : else }
< p class = "setting-description" > Failed to load configuration status.< / p >
{ /if }
< / div >
< div class = "setting-group" >
< div class = "setting-group" >
< ForwardingConfig
< ForwardingConfig
userPubkeyHex={ $userStore . userPubkeyHex }
userPubkeyHex={ $userStore . userPubkeyHex }
@ -626,4 +988,120 @@
opacity: 0.6;
opacity: 0.6;
cursor: not-allowed;
cursor: not-allowed;
}
}
.setting-section-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-color);
}
.config-status {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 1rem;
}
.config-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.config-item:last-child {
border-bottom: none;
}
.config-label {
font-weight: 500;
color: var(--text-primary);
}
.config-value {
color: var(--text-secondary);
font-family: monospace;
font-size: 0.875rem;
}
.config-value.configured {
color: var(--success-color, #10b981);
font-weight: 600;
}
.config-section {
margin-bottom: 1rem;
border: 1px solid var(--border-color);
border-radius: 0.375rem;
overflow: hidden;
}
.config-section-header {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-secondary);
border: none;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
transition: background 0.2s ease;
}
.config-section-header:hover {
background: var(--bg-tertiary);
}
.section-title {
flex: 1;
text-align: left;
}
.section-toggle {
font-size: 0.875rem;
color: var(--text-secondary);
margin-left: 1rem;
}
.config-docs {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--bg-tertiary);
border-radius: 0.25rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.config-docs p {
margin: 0.5rem 0;
}
.config-docs p:first-child {
margin-top: 0;
}
.config-docs p:last-child {
margin-bottom: 0;
}
.config-docs code {
background: var(--bg-primary);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.8125rem;
color: var(--text-primary);
}
.config-docs strong {
color: var(--text-primary);
font-weight: 600;
}
< / style >
< / style >