Skip to content

Commit bf0a083

Browse files
Add username and password configuration to settings UI
Enable basic authentication credentials to be configured directly from the settings page, removing the need to edit settings.json manually.
1 parent 5539ffd commit bf0a083

5 files changed

Lines changed: 189 additions & 2 deletions

File tree

server/routes/config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export const CONFIG_KEYS = {
2626
LINEAR_API_KEY: 'linearApiKey',
2727
GITHUB_PAT: 'githubPat',
2828
LANGUAGE: 'language',
29+
BASIC_AUTH_USERNAME: 'basicAuthUsername',
30+
BASIC_AUTH_PASSWORD: 'basicAuthPassword',
2931
} as const
3032

3133
const app = new Hono()
@@ -187,6 +189,11 @@ app.get('/:key', (c) => {
187189
value = settings.githubPat
188190
} else if (key === 'language' || key === CONFIG_KEYS.LANGUAGE) {
189191
return c.json({ key, value: settings.language, isDefault: settings.language === null })
192+
} else if (key === 'basic_auth_username' || key === CONFIG_KEYS.BASIC_AUTH_USERNAME) {
193+
return c.json({ key, value: settings.basicAuthUsername, isDefault: settings.basicAuthUsername === null })
194+
} else if (key === 'basic_auth_password' || key === CONFIG_KEYS.BASIC_AUTH_PASSWORD) {
195+
// For security, don't return the actual password value, just whether it's set
196+
return c.json({ key, value: settings.basicAuthPassword ? '••••••••' : null, isDefault: settings.basicAuthPassword === null })
190197
} else if (key === 'worktree_base_path') {
191198
// Read-only: derived from VIBORA_DIR
192199
return c.json({ key, value: getWorktreeBasePath(), isDefault: true })
@@ -252,6 +259,19 @@ app.put('/:key', async (c) => {
252259
}
253260
updateSettings({ language: langValue as 'en' | 'zh' | null })
254261
return c.json({ key, value: langValue })
262+
} else if (key === 'basic_auth_username' || key === CONFIG_KEYS.BASIC_AUTH_USERNAME) {
263+
if (typeof body.value !== 'string') {
264+
return c.json({ error: 'Value must be a string' }, 400)
265+
}
266+
updateSettings({ basicAuthUsername: body.value || null })
267+
return c.json({ key, value: body.value })
268+
} else if (key === 'basic_auth_password' || key === CONFIG_KEYS.BASIC_AUTH_PASSWORD) {
269+
if (typeof body.value !== 'string') {
270+
return c.json({ error: 'Value must be a string' }, 400)
271+
}
272+
updateSettings({ basicAuthPassword: body.value || null })
273+
// Don't return the actual password
274+
return c.json({ key, value: body.value ? '••••••••' : null })
255275
} else {
256276
return c.json({ error: `Unknown or read-only config key: ${key}` }, 400)
257277
}
@@ -282,6 +302,10 @@ app.delete('/:key', (c) => {
282302
defaultValue = defaults.githubPat
283303
} else if (key === 'language' || key === CONFIG_KEYS.LANGUAGE) {
284304
defaultValue = defaults.language
305+
} else if (key === 'basic_auth_username' || key === CONFIG_KEYS.BASIC_AUTH_USERNAME) {
306+
defaultValue = defaults.basicAuthUsername
307+
} else if (key === 'basic_auth_password' || key === CONFIG_KEYS.BASIC_AUTH_PASSWORD) {
308+
defaultValue = defaults.basicAuthPassword
285309
}
286310

287311
return c.json({ key, value: defaultValue, isDefault: true })

src/hooks/use-config.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export const CONFIG_KEYS = {
2020
LINEAR_API_KEY: 'linear_api_key',
2121
GITHUB_PAT: 'github_pat',
2222
LANGUAGE: 'language',
23+
BASIC_AUTH_USERNAME: 'basic_auth_username',
24+
BASIC_AUTH_PASSWORD: 'basic_auth_password',
2325
} as const
2426

2527
// Default values (client-side fallbacks)
@@ -115,6 +117,27 @@ export function useLanguage() {
115117
}
116118
}
117119

120+
export function useBasicAuthUsername() {
121+
const query = useConfig(CONFIG_KEYS.BASIC_AUTH_USERNAME)
122+
123+
return {
124+
...query,
125+
data: (query.data?.value as string) ?? '',
126+
isDefault: query.data?.isDefault ?? true,
127+
}
128+
}
129+
130+
export function useBasicAuthPassword() {
131+
const query = useConfig(CONFIG_KEYS.BASIC_AUTH_PASSWORD)
132+
133+
return {
134+
...query,
135+
// Password is masked on the server, so we just check if it's set
136+
data: (query.data?.value as string) ?? '',
137+
isDefault: query.data?.isDefault ?? true,
138+
}
139+
}
140+
118141
export function useUpdateConfig() {
119142
const queryClient = useQueryClient()
120143

src/i18n/locales/en/settings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"sections": {
44
"serverDefaults": "Server & Defaults",
55
"paths": "Paths",
6+
"authentication": "Authentication",
67
"remoteAccess": "Remote Access",
78
"integrations": "Integrations",
89
"zai": "z.ai",
@@ -40,6 +41,14 @@
4041
"label": "GitHub",
4142
"description": "PAT for Issues & PRs"
4243
},
44+
"auth": {
45+
"username": "Username",
46+
"password": "Password",
47+
"usernamePlaceholder": "Leave empty to disable",
48+
"passwordPlaceholder": "Leave empty to disable",
49+
"passwordSet": "Enter new password to change",
50+
"description": "Set both username and password to enable basic authentication. Leave empty to disable."
51+
},
4352
"zai": {
4453
"enable": "Enable",
4554
"apiKey": "API Key",

src/i18n/locales/zh/settings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"sections": {
44
"serverDefaults": "服务器与默认设置",
55
"paths": "路径",
6+
"authentication": "身份验证",
67
"remoteAccess": "远程访问",
78
"integrations": "集成",
89
"zai": "z.ai",
@@ -40,6 +41,14 @@
4041
"label": "GitHub",
4142
"description": "用于 Issues 和 PR 的个人访问令牌"
4243
},
44+
"auth": {
45+
"username": "用户名",
46+
"password": "密码",
47+
"usernamePlaceholder": "留空以禁用",
48+
"passwordPlaceholder": "留空以禁用",
49+
"passwordSet": "输入新密码以更改",
50+
"description": "同时设置用户名和密码以启用基本身份验证。留空以禁用。"
51+
},
4352
"zai": {
4453
"enable": "启用",
4554
"apiKey": "API 密钥",

src/routes/settings/index.tsx

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
useSshPort,
2323
useLinearApiKey,
2424
useGitHubPat,
25+
useBasicAuthUsername,
26+
useBasicAuthPassword,
2527
useUpdateConfig,
2628
useResetConfig,
2729
useNotificationSettings,
@@ -59,6 +61,8 @@ function SettingsPage() {
5961
const { data: sshPort, isLoading: sshPortLoading } = useSshPort()
6062
const { data: linearApiKey, isLoading: linearApiKeyLoading } = useLinearApiKey()
6163
const { data: githubPat, isLoading: githubPatLoading } = useGitHubPat()
64+
const { data: basicAuthUsername, isLoading: basicAuthUsernameLoading } = useBasicAuthUsername()
65+
const { data: basicAuthPassword, isLoading: basicAuthPasswordLoading } = useBasicAuthPassword()
6266
const { data: notificationSettings, isLoading: notificationsLoading } = useNotificationSettings()
6367
const { data: zAiSettings, isLoading: zAiLoading } = useZAiSettings()
6468
const { data: developerMode } = useDeveloperMode()
@@ -76,6 +80,8 @@ function SettingsPage() {
7680
const [localSshPort, setLocalSshPort] = useState('')
7781
const [localLinearApiKey, setLocalLinearApiKey] = useState('')
7882
const [localGitHubPat, setLocalGitHubPat] = useState('')
83+
const [localBasicAuthUsername, setLocalBasicAuthUsername] = useState('')
84+
const [localBasicAuthPassword, setLocalBasicAuthPassword] = useState('')
7985
const [reposDirBrowserOpen, setReposDirBrowserOpen] = useState(false)
8086
const [saved, setSaved] = useState(false)
8187

@@ -108,7 +114,10 @@ function SettingsPage() {
108114
if (sshPort !== undefined) setLocalSshPort(String(sshPort))
109115
if (linearApiKey !== undefined) setLocalLinearApiKey(linearApiKey)
110116
if (githubPat !== undefined) setLocalGitHubPat(githubPat)
111-
}, [port, defaultGitReposDir, hostname, sshPort, linearApiKey, githubPat])
117+
// For username, sync directly. For password, the server returns masked value - only update if empty (not yet loaded)
118+
if (basicAuthUsername !== undefined) setLocalBasicAuthUsername(basicAuthUsername)
119+
// Don't sync password from server since it's masked - user must re-enter to change
120+
}, [port, defaultGitReposDir, hostname, sshPort, linearApiKey, githubPat, basicAuthUsername])
112121

113122
// Sync notification settings
114123
useEffect(() => {
@@ -137,7 +146,7 @@ function SettingsPage() {
137146
}, [zAiSettings])
138147

139148
const isLoading =
140-
portLoading || reposDirLoading || hostnameLoading || sshPortLoading || linearApiKeyLoading || githubPatLoading || notificationsLoading || zAiLoading
149+
portLoading || reposDirLoading || hostnameLoading || sshPortLoading || linearApiKeyLoading || githubPatLoading || basicAuthUsernameLoading || basicAuthPasswordLoading || notificationsLoading || zAiLoading
141150

142151
const hasZAiChanges = zAiSettings && (
143152
zAiEnabled !== zAiSettings.enabled ||
@@ -159,13 +168,19 @@ function SettingsPage() {
159168
pushoverUserKey !== (notificationSettings.pushover?.userKey ?? '')
160169
)
161170

171+
// Auth has changes if username differs OR password is non-empty (user wants to update it)
172+
const hasAuthChanges =
173+
localBasicAuthUsername !== basicAuthUsername ||
174+
localBasicAuthPassword !== ''
175+
162176
const hasChanges =
163177
localPort !== String(port) ||
164178
localReposDir !== defaultGitReposDir ||
165179
localHostname !== hostname ||
166180
localSshPort !== String(sshPort) ||
167181
localLinearApiKey !== linearApiKey ||
168182
localGitHubPat !== githubPat ||
183+
hasAuthChanges ||
169184
hasNotificationChanges ||
170185
hasZAiChanges
171186

@@ -241,6 +256,31 @@ function SettingsPage() {
241256
)
242257
}
243258

259+
// Save auth settings
260+
if (localBasicAuthUsername !== basicAuthUsername) {
261+
promises.push(
262+
new Promise((resolve) => {
263+
updateConfig.mutate(
264+
{ key: CONFIG_KEYS.BASIC_AUTH_USERNAME, value: localBasicAuthUsername },
265+
{ onSettled: resolve }
266+
)
267+
})
268+
)
269+
}
270+
271+
if (localBasicAuthPassword !== '') {
272+
promises.push(
273+
new Promise((resolve) => {
274+
updateConfig.mutate(
275+
{ key: CONFIG_KEYS.BASIC_AUTH_PASSWORD, value: localBasicAuthPassword },
276+
{ onSettled: resolve }
277+
)
278+
})
279+
)
280+
// Clear local password after saving
281+
setLocalBasicAuthPassword('')
282+
}
283+
244284
// Save notification settings
245285
if (hasNotificationChanges) {
246286
promises.push(
@@ -332,6 +372,22 @@ function SettingsPage() {
332372
})
333373
}
334374

375+
const handleResetBasicAuthUsername = () => {
376+
resetConfig.mutate(CONFIG_KEYS.BASIC_AUTH_USERNAME, {
377+
onSuccess: (data) => {
378+
setLocalBasicAuthUsername(data.value !== null && data.value !== undefined ? String(data.value) : '')
379+
},
380+
})
381+
}
382+
383+
const handleResetBasicAuthPassword = () => {
384+
resetConfig.mutate(CONFIG_KEYS.BASIC_AUTH_PASSWORD, {
385+
onSuccess: () => {
386+
setLocalBasicAuthPassword('')
387+
},
388+
})
389+
}
390+
335391
const handleTestChannel = async (channel: 'sound' | 'slack' | 'discord' | 'pushover') => {
336392
testChannel.mutate(channel, {
337393
onSuccess: (result) => {
@@ -432,6 +488,72 @@ function SettingsPage() {
432488
</div>
433489
</SettingsSection>
434490

491+
{/* Authentication */}
492+
<SettingsSection title={t('sections.authentication')}>
493+
<div className="space-y-4">
494+
{/* Username */}
495+
<div className="space-y-1">
496+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
497+
<label className="text-sm text-muted-foreground sm:w-32 sm:shrink-0">
498+
{t('fields.auth.username')}
499+
</label>
500+
<div className="flex flex-1 items-center gap-2">
501+
<Input
502+
value={localBasicAuthUsername}
503+
onChange={(e) => setLocalBasicAuthUsername(e.target.value)}
504+
placeholder={t('fields.auth.usernamePlaceholder')}
505+
disabled={isLoading}
506+
className="flex-1 font-mono text-sm"
507+
/>
508+
<Button
509+
variant="ghost"
510+
size="icon"
511+
className="h-8 w-8 text-muted-foreground hover:text-foreground"
512+
onClick={handleResetBasicAuthUsername}
513+
disabled={isLoading || resetConfig.isPending}
514+
title={tc('buttons.reset')}
515+
>
516+
<HugeiconsIcon icon={RotateLeft01Icon} size={14} strokeWidth={2} />
517+
</Button>
518+
</div>
519+
</div>
520+
</div>
521+
522+
{/* Password */}
523+
<div className="space-y-1">
524+
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
525+
<label className="text-sm text-muted-foreground sm:w-32 sm:shrink-0">
526+
{t('fields.auth.password')}
527+
</label>
528+
<div className="flex flex-1 items-center gap-2">
529+
<Input
530+
type="password"
531+
value={localBasicAuthPassword}
532+
onChange={(e) => setLocalBasicAuthPassword(e.target.value)}
533+
placeholder={basicAuthPassword ? t('fields.auth.passwordSet') : t('fields.auth.passwordPlaceholder')}
534+
disabled={isLoading}
535+
className="flex-1 font-mono text-sm"
536+
/>
537+
<Button
538+
variant="ghost"
539+
size="icon"
540+
className="h-8 w-8 text-muted-foreground hover:text-foreground"
541+
onClick={handleResetBasicAuthPassword}
542+
disabled={isLoading || resetConfig.isPending}
543+
title={tc('buttons.reset')}
544+
>
545+
<HugeiconsIcon icon={RotateLeft01Icon} size={14} strokeWidth={2} />
546+
</Button>
547+
</div>
548+
</div>
549+
</div>
550+
551+
<p className="text-xs text-muted-foreground">
552+
{t('fields.auth.description')}
553+
</p>
554+
</div>
555+
</SettingsSection>
556+
435557
{/* Remote Access + Integrations side by side */}
436558
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
437559
{/* Remote Access */}

0 commit comments

Comments
 (0)