Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions core/http/react-ui/src/components/RequireAuth.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'

export default function RequireAuth({ children }) {
const { authEnabled, user, loading } = useAuth()
const { authEnabled, staticApiKeyRequired, user, loading } = useAuth()
if (loading) return null
if (authEnabled && !user) return <Navigate to="/login" replace />
if ((authEnabled || staticApiKeyRequired) && !user) return <Navigate to="/login" replace />
return children
}
11 changes: 8 additions & 3 deletions core/http/react-ui/src/context/AuthContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function AuthProvider({ children }) {
const [state, setState] = useState({
loading: true,
authEnabled: false,
staticApiKeyRequired: false,
user: null,
permissions: {},
})
Expand All @@ -20,12 +21,13 @@ export function AuthProvider({ children }) {
setState({
loading: false,
authEnabled: data.authEnabled || false,
staticApiKeyRequired: data.staticApiKeyRequired || false,
user,
permissions,
})
})
.catch(() => {
setState({ loading: false, authEnabled: false, user: null, permissions: {} })
setState({ loading: false, authEnabled: false, staticApiKeyRequired: false, user: null, permissions: {} })
})
}

Expand All @@ -45,17 +47,20 @@ export function AuthProvider({ children }) {

const refresh = () => fetchStatus()

const noAuthRequired = !state.authEnabled && !state.staticApiKeyRequired

const hasFeature = (name) => {
if (state.user?.role === 'admin' || !state.authEnabled) return true
if (state.user?.role === 'admin' || noAuthRequired) return true
return !!state.permissions[name]
}

const value = {
loading: state.loading,
authEnabled: state.authEnabled,
staticApiKeyRequired: state.staticApiKeyRequired,
user: state.user,
permissions: state.permissions,
isAdmin: state.user?.role === 'admin' || !state.authEnabled,
isAdmin: state.user?.role === 'admin' || noAuthRequired,
hasFeature,
logout,
refresh,
Expand Down
38 changes: 36 additions & 2 deletions core/http/react-ui/src/pages/Login.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function Login() {
const navigate = useNavigate()
const { code: urlInviteCode } = useParams()
const [searchParams] = useSearchParams()
const { authEnabled, user, loading: authLoading, refresh } = useAuth()
const { authEnabled, staticApiKeyRequired, user, loading: authLoading, refresh } = useAuth()
const [providers, setProviders] = useState([])
const [hasUsers, setHasUsers] = useState(true)
const [registrationMode, setRegistrationMode] = useState('open')
Expand Down Expand Up @@ -66,7 +66,7 @@ export default function Login() {

// Redirect if auth is disabled or user is already logged in
useEffect(() => {
if (!authLoading && (!authEnabled || user)) {
if (!authLoading && ((!authEnabled && !staticApiKeyRequired) || user)) {
navigate('/app', { replace: true })
}
}, [authLoading, authEnabled, user, navigate])
Expand Down Expand Up @@ -176,6 +176,40 @@ export default function Login() {

if (authLoading || statusLoading) return null

// Legacy API key-only mode: show a simplified login with just the token input
if (staticApiKeyRequired && !authEnabled) {
return (
<div className="login-page">
<div className="card login-card">
<div className="login-header">
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="login-logo" />
<p className="login-subtitle">Enter your API key to continue</p>
</div>

{error && (
<div className="login-alert login-alert-error">{error}</div>
)}

<form onSubmit={handleTokenLogin}>
<div className="form-group">
<input
className="input"
type="password"
value={token}
onChange={(e) => { setToken(e.target.value); setError('') }}
placeholder="Enter API key..."
autoFocus
/>
</div>
<button type="submit" className="btn btn-primary login-btn-full" disabled={submitting}>
{submitting ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}

const hasGitHub = providers.includes('github')
const hasOIDC = providers.includes('oidc')
const hasLocal = providers.includes('local')
Expand Down
9 changes: 5 additions & 4 deletions core/http/routes/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,11 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
}

resp := map[string]any{
"authEnabled": authEnabled,
"providers": providers,
"hasUsers": hasUsers,
"registrationMode": registrationMode,
"authEnabled": authEnabled,
"staticApiKeyRequired": !authEnabled && len(appConfig.ApiKeys) > 0,
"providers": providers,
"hasUsers": hasUsers,
"registrationMode": registrationMode,
}

// Include current user if authenticated
Expand Down
30 changes: 27 additions & 3 deletions core/http/routes/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@ func newTestAuthApp(db *gorm.DB, appConfig *config.ApplicationConfig) *echo.Echo
}

resp := map[string]any{
"authEnabled": authEnabled,
"providers": providers,
"hasUsers": hasUsers,
"authEnabled": authEnabled,
"staticApiKeyRequired": !authEnabled && len(appConfig.ApiKeys) > 0,
"providers": providers,
"hasUsers": hasUsers,
}

user := auth.GetUser(c)
Expand Down Expand Up @@ -407,6 +408,29 @@ var _ = Describe("Auth Routes", Label("auth"), func() {
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["hasUsers"]).To(BeFalse())
})

It("returns staticApiKeyRequired=true when no DB but API keys configured", func() {
cfg := config.NewApplicationConfig()
config.WithApiKeys([]string{"test-key-123"})(cfg)
app := newTestAuthApp(nil, cfg)
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
Expect(rec.Code).To(Equal(http.StatusOK))

var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["authEnabled"]).To(BeFalse())
Expect(resp["staticApiKeyRequired"]).To(BeTrue())
})

It("returns staticApiKeyRequired=false when no DB and no API keys", func() {
app := newTestAuthApp(nil, config.NewApplicationConfig())
rec := doAuthRequest(app, "GET", "/api/auth/status", nil)
Expect(rec.Code).To(Equal(http.StatusOK))

var resp map[string]any
json.Unmarshal(rec.Body.Bytes(), &resp)
Expect(resp["staticApiKeyRequired"]).To(BeFalse())
})
})

Context("POST /api/auth/logout", func() {
Expand Down
Loading