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:
@ -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 {
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
5
apps/desktop/src/global.d.ts
vendored
5
apps/desktop/src/global.d.ts
vendored
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user