Skip to content

Commit fcfb3ba

Browse files
Merge branch 'make-username-and-password-configurable-from-the-settings-ui-26m6'
2 parents 5539ffd + bf0a083 commit fcfb3ba

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)