feat(desktop): username/password login for remote gateways (#38851)

Surface the username/password dashboard-auth provider in Hermes Desktop's
remote-gateway connect flow. A password gateway gates the same way an OAuth
one does (auth_required + session cookie + ws-ticket), so the desktop already
drives it through the existing sign-in window; the only gaps were that the
probe dropped supports_password and the UI always said "OAuth".

- main.cjs: capture supports_password from /api/auth/providers in the probe.
- global.d.ts: add optional supportsPassword to DesktopAuthProvider.
- gateway-settings.tsx: derive isPasswordProvider; render a plain "Sign in"
  button + "username and password" copy instead of an OAuth provider label
  when every advertised provider is password-based. Login still flows through
  the gateway's /login credential form (POST /auth/password-login).
This commit is contained in:
Teknium
2026-06-04 01:33:23 -07:00
committed by GitHub
parent fe709a4210
commit bd12b3c232
3 changed files with 37 additions and 9 deletions

View File

@ -3624,15 +3624,21 @@ async function probeRemoteAuthMode(rawUrl) {
let providers = []
if (authRequired) {
// Best-effort: a gated gateway exposes the registered OAuth providers so
// the button can read "Log in with Nous Research" instead of a generic
// label. A failure here doesn't change the auth mode, so swallow it.
// Best-effort: a gated gateway exposes the registered providers so the
// button can read "Sign in with Nous Research" instead of a generic
// label, and so a username/password provider can be distinguished from
// an OAuth-redirect one (``supports_password``). A failure here doesn't
// change the auth mode, so swallow it.
try {
const body = await fetchPublicJson(`${baseUrl}/api/auth/providers`, { timeoutMs: 8_000 })
if (Array.isArray(body?.providers)) {
providers = body.providers
.filter(p => p && typeof p === 'object')
.map(p => ({ name: String(p.name || ''), displayName: String(p.display_name || p.name || '') }))
.map(p => ({
name: String(p.name || ''),
displayName: String(p.display_name || p.name || ''),
supportsPassword: Boolean(p.supports_password)
}))
.filter(p => p.name)
}
} catch {

View File

@ -209,6 +209,19 @@ export function GatewaySettings() {
return 'your identity provider'
}, [probe])
// A username/password gateway authenticates through a credential form on the
// gateway's /login page (POST /auth/password-login) rather than an OAuth
// redirect. Everything downstream — the session cookie, the ws-ticket mint,
// the persistent partition — is identical, so the desktop drives it through
// the same sign-in window; only the button copy changes. We treat the
// gateway as password-style only when EVERY advertised provider supports
// password, so a mixed deployment keeps the generic OAuth copy.
const isPasswordProvider = useMemo(() => {
const providers: DesktopAuthProvider[] = probe?.providers ?? []
return providers.length > 0 && providers.every(p => p.supportsPassword)
}, [probe])
const oauthConnected = state.remoteOauthConnected
const canUseRemote = useMemo(() => {
@ -409,7 +422,7 @@ export function GatewaySettings() {
/>
<ModeCard
active={state.mode === 'remote'}
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth; self-hosted ones may use a session token."
description="Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token."
disabled={state.envOverride}
icon={Globe}
onSelect={() => setState(current => ({ ...current, mode: 'remote' }))}
@ -446,7 +459,7 @@ export function GatewaySettings() {
</div>
) : null}
{/* OAuth gateways: present a sign-in button + connection status. */}
{/* OAuth / password gateways: present a sign-in button + connection status. */}
{state.mode === 'remote' && authResolved && authMode === 'oauth' ? (
<ListRow
action={
@ -463,14 +476,18 @@ export function GatewaySettings() {
) : (
<Button disabled={signingIn || state.envOverride || !trimmedUrl} onClick={() => void signIn()}>
{signingIn ? <Loader2 className="size-4 animate-spin" /> : <LogIn className="size-4" />}
Sign in with {providerLabel}
{isPasswordProvider ? 'Sign in' : `Sign in with ${providerLabel}`}
</Button>
)
}
description={
oauthConnected
? 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
? isPasswordProvider
? 'This gateway uses a username and password. You are signed in; the session refreshes automatically.'
: 'This gateway uses OAuth. You are signed in; the session refreshes automatically.'
: isPasswordProvider
? 'This gateway uses a username and password. Sign in to authorize this desktop app.'
: `This gateway uses OAuth. Sign in with ${providerLabel} to authorize this desktop app.`
}
title="Authentication"
/>

View File

@ -191,6 +191,11 @@ export interface DesktopConnectionTestResult {
export interface DesktopAuthProvider {
name: string
displayName: string
// True when this provider authenticates with a username + password
// (the gateway's /login page renders a credential form) rather than an
// OAuth redirect. The session/cookie/ws-ticket machinery is identical;
// only the login-page form and the desktop's button copy differ.
supportsPassword?: boolean
}
export interface DesktopConnectionProbeResult {