Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import React from "react";
import { PluginLoginMethodSelection, LoginConfig } from "../pluginLoginMethodSelection";

class DefaultLoginMethodSelectionPlugin implements PluginLoginMethodSelection {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
renderMethodSelection(_loginConfig: LoginConfig | undefined): React.ReactElement | null {
return null;
}
}

export default new DefaultLoginMethodSelectionPlugin();
17 changes: 17 additions & 0 deletions webui/src/extendable/plugins/pluginLoginMethodSelection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from "react";

export interface LoginConfig {
login_url: string;
username_ui_placeholder: string;
password_ui_placeholder: string;
login_failed_message?: string;
fallback_login_url?: string;
fallback_login_label?: string;
login_cookie_names: string[];
logout_url: string;
select_login_method?: boolean;
}

export interface PluginLoginMethodSelection {
renderMethodSelection: (loginConfig: LoginConfig | undefined) => React.ReactElement | null;
}
13 changes: 12 additions & 1 deletion webui/src/extendable/plugins/pluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ import { PluginRepoCreationForm } from "./pluginRepoCreationForm";
import DefaultRepoCreationFormPlugin from "./impls/DefaultRepoCreationFormPlugin";
import { PluginCustomObjectRenderers } from "./pluginCustomObjectRenderers";
import DefaultCustomObjectRenderersPlugin from "./impls/DefaultCustomObjectRenderers";
import { PluginLoginMethodSelection } from "./pluginLoginMethodSelection";
import DefaultLoginMethodSelectionPlugin from "./impls/DefaultLoginMethodSelectionPlugin";

export class PluginManager {
private _repoCreationForm: PluginRepoCreationForm = DefaultRepoCreationFormPlugin;
private _customObjectRenderers: PluginCustomObjectRenderers = DefaultCustomObjectRenderersPlugin;
private _loginMethodSelection: PluginLoginMethodSelection = DefaultLoginMethodSelectionPlugin;

overridePluginRepoCreationForm(pluginRepoCreationForm: PluginRepoCreationForm): void {
this._repoCreationForm = pluginRepoCreationForm;
Expand All @@ -22,4 +25,12 @@ export class PluginManager {
get customObjectRenderers(): PluginCustomObjectRenderers {
return this._customObjectRenderers;
}
}

overridePluginLoginMethodSelection(pluginLoginMethodSelection: PluginLoginMethodSelection): void {
this._loginMethodSelection = pluginLoginMethodSelection;
}

get loginMethodSelection(): PluginLoginMethodSelection {
return this._loginMethodSelection;
}
}
50 changes: 21 additions & 29 deletions webui/src/lib/components/navbar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,39 +64,31 @@ const TopNavLink = ({ href, children }) => {
};

const TopNav = ({logged = true}) => {
if (!logged) {
if (logged) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

code style - can we !logged and return early or return logged && the component so it will take the code indent a bit left.

return (
<Navbar variant="dark" bg="dark" expand="md">
<Container fluid={true}>
<Link component={Navbar.Brand} href="/">
<img src="/logo.png" alt="lakeFS" className="logo"/>
</Link>
</Container>
<Navbar variant="dark" bg="dark" expand="md" className="border-bottom">
<Container fluid={true}>
<Link component={Navbar.Brand} href="/">
<img src="/logo.png" alt="lakeFS" className="logo"/>
</Link>
<Navbar.Toggle aria-controls="navbarScroll" />
<Navbar.Collapse id="navbarScroll">

<Nav className="me-auto my-2 my-lg-0"
style={{ maxHeight: '100px' }}
navbarScroll>
<TopNavLink href="/repositories">Repositories</TopNavLink>
<TopNavLink href="/auth">Administration</TopNavLink>
</Nav>

<DarkModeToggle/>
<NavUserInfo/>
</Navbar.Collapse>
</Container>
</Navbar>
);
}
return (
<Navbar variant="dark" bg="dark" expand="md" className="border-bottom">
<Container fluid={true}>
<Link component={Navbar.Brand} href="/">
<img src="/logo.png" alt="lakeFS" className="logo"/>
</Link>
<Navbar.Toggle aria-controls="navbarScroll" />
<Navbar.Collapse id="navbarScroll">

<Nav className="me-auto my-2 my-lg-0"
style={{ maxHeight: '100px' }}
navbarScroll>
<TopNavLink href="/repositories">Repositories</TopNavLink>
<TopNavLink href="/auth">Administration</TopNavLink>
</Nav>

<DarkModeToggle/>
<NavUserInfo/>
</Navbar.Collapse>
</Container>
</Navbar>
);
return null;
};

export default TopNav;
182 changes: 109 additions & 73 deletions webui/src/pages/auth/login.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import React, {useState} from "react";
import Row from "react-bootstrap/Row";
import Card from "react-bootstrap/Card";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import {auth, AuthenticationError, setup, SETUP_STATE_INITIALIZED} from "../../lib/api";
import {AlertError} from "../../lib/components/controls"
import {useRouter} from "../../lib/hooks/router";
import {useAPI} from "../../lib/hooks/api";
import {useNavigate} from "react-router-dom";
import {usePluginManager} from "../../extendable/plugins/pluginsContext";

interface SetupResponse {
state: string;
comm_prefs_missing?: boolean;
login_config?: LoginConfig;
}

interface LoginConfig {
login_url: string;
Expand All @@ -19,111 +24,142 @@ interface LoginConfig {
fallback_login_label?: string;
login_cookie_names: string[];
logout_url: string;
select_login_method?: boolean;
}

const LoginForm = ({loginConfig}: {loginConfig: LoginConfig}) => {
const router = useRouter();
const navigate = useNavigate();
const [loginError, setLoginError] = useState(null);
const [loginError, setLoginError] = useState<React.ReactNode>(null);
const { next } = router.query;
const usernamePlaceholder = loginConfig.username_ui_placeholder || "Access Key ID";
const passwordPlaceholder = loginConfig.password_ui_placeholder || "Secret Access Key";

return (
<Row>
<Col md={{offset: 4, span: 4}}>
<Card className="login-widget shadow-lg border-0">
<Card.Header className="text">
<h4 className="mb-0">Login</h4>
</Card.Header>
<Card.Body className="p-4">
<Form onSubmit={async (e) => {
e.preventDefault()
try {
setLoginError(null);
await auth.login(e.target.username.value, e.target.password.value)
router.push(next || '/');
navigate(0);
} catch(err) {
if (err instanceof AuthenticationError && err.status === 401) {
const contents = {__html: `${loginConfig.login_failed_message}` ||
"Credentials don't match."};
setLoginError(<span dangerouslySetInnerHTML={contents}/>);
}
<div className="d-flex align-items-center justify-content-center login-container">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login-container (and its css) isn't needed, and positioning can / should be simplified.

Just define for the login-card:
margin: 50px auto auto auto

It will place it at a fixed margin from the top, and center it horizontally.

And in addition, vertical centering is tricky with html + css, and prone for errors.

<Card className="login-widget shadow-lg border-0 login-card">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why both login-widget and login-card?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The login-widget was already here before my changes. I saw that there’s no actual CSS class with that name, and the only reference I found was in the design/accepted/ui-testing.md document, where it was used as a selector in this old design doc. So I removed it.

<Card.Header className="text-center">
<div className="mb-3">
<img src="/logo.png" alt="lakeFS" className="login-logo" />
</div>
<h4 className="mb-0">Login</h4>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is more for @nopcoder , I guess, but -
There's already a button that says Login,
So there's no need to repeat it (is creates an unnecessary cognitive load on the user).
IMO this h4 can simply be removed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree I removed it. Let's see what @nopcoder thinks about that and the whole appearance change.

</Card.Header>
<Card.Body className="p-4">
<Form onSubmit={async (e) => {
e.preventDefault()
const form = e.target as HTMLFormElement;
const formData = new FormData(form);
try {
setLoginError(null);
const username = formData.get('username');
const password = formData.get('password');
if (typeof username === 'string' && typeof password === 'string') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this allow empty strings?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@itaigilo
Yes, this check allows empty strings, but I actually removed this:

if (typeof username === 'string' && typeof password === 'string')

before the call to:

await auth.login(username, password);

because in the previous code we didn’t have this check, and I don’t want to change the logic. If formData.get('username') or formData.get('password') are not strings, the auth.login() call will handle them the same way it did before (resulting in a 401 response for the request).

await auth.login(username, password);
}
}}>
<Form.Group controlId="username" className="mb-3">
<Form.Control
type="text"
placeholder={usernamePlaceholder}
autoFocus
className="bg-light"
/>
</Form.Group>

<Form.Group controlId="password" className="mb-3">
<Form.Control
type="password"
placeholder={passwordPlaceholder}
className="bg-light"
/>
</Form.Group>

{(!!loginError) && <AlertError error={loginError}/>}

<Button
variant="primary"
type="submit"
className="w-100 mt-3 py-2"
>
Login
</Button>
</Form>
<div className={"mt-2 mb-1"}>
{ loginConfig.fallback_login_url ?
<Button variant="link" className="text-secondary mt-2" onClick={async ()=> {
loginConfig.login_cookie_names?.forEach(
cookie => {
document.cookie = `${cookie}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
}
);
window.location = loginConfig.fallback_login_url;
}}>{loginConfig.fallback_login_label || 'Try another way to login'}</Button>
: ""
router.push(next || '/');
navigate(0);
} catch(err) {
if (err instanceof AuthenticationError && err.status === 401) {
const contents = {__html: `${loginConfig.login_failed_message}` ||
"Credentials don't match."};
setLoginError(<span dangerouslySetInnerHTML={contents}/>);
}
</div>
</Card.Body>
</Card>
</Col>
</Row>
}
}}>
<Form.Group controlId="username" className="mb-3">
<Form.Control
name="username"
type="text"
placeholder={usernamePlaceholder}
autoFocus
className="bg-light"
/>
</Form.Group>

<Form.Group controlId="password" className="mb-3">
<Form.Control
name="password"
type="password"
placeholder={passwordPlaceholder}
className="bg-light"
/>
</Form.Group>

{(!!loginError) && <AlertError error={loginError}/>}

<Button
variant="primary"
type="submit"
className="w-100 mt-3 py-2"
>
Login
</Button>
</Form>
<div className={"mt-2 mb-1"}>
{ loginConfig.fallback_login_url ?
<Button variant="link" className="text-secondary mt-2" onClick={async ()=> {
loginConfig.login_cookie_names?.forEach(
cookie => {
document.cookie = `${cookie}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
}
);
if (loginConfig.fallback_login_url) {
window.location.href = loginConfig.fallback_login_url;
}
}}>{loginConfig.fallback_login_label || 'Try another way to login'}</Button>
: ""
}
</div>
</Card.Body>
</Card>
</div>
)
}



const LoginPage = () => {
const router = useRouter();
const { response, error, loading } = useAPI(() => setup.getState());
const pluginManager = usePluginManager();

if (loading) {
return null;
}

// if we are not initialized, or we are not done with comm prefs, redirect to 'setup' page
if (!error && response && (response.state !== SETUP_STATE_INITIALIZED || response.comm_prefs_missing === true)) {
router.push({pathname: '/setup', query: router.query})
if (!error && response && ((response as SetupResponse).state !== SETUP_STATE_INITIALIZED || (response as SetupResponse).comm_prefs_missing)) {
router.push({pathname: '/setup', params: {}, query: router.query as Record<string, string>})
return null;
}
const loginConfig = response?.login_config;
const setupResponse = response as SetupResponse | null;
const loginConfig = setupResponse?.login_config;

const loginMethodSelectionComponent = loginConfig ? pluginManager.loginMethodSelection.renderMethodSelection(loginConfig) : null;
if (loginMethodSelectionComponent) {
if (router.query.method === 'local' && loginConfig) {
return <LoginForm loginConfig={loginConfig}/>;
}

return loginMethodSelectionComponent;
}

if (router.query.redirected) {
if(!error && loginConfig?.login_url) {
window.location = loginConfig.login_url;
window.location.href = loginConfig.login_url;
return null;
}
delete router.query.redirected;

router.push({pathname: '/auth/login', query: router.query})
router.push({pathname: '/auth/login', params: {}, query: router.query as Record<string, string>})
}
if (!loginConfig) {
return null;
}

return (
<LoginForm loginConfig={loginConfig}/>
);
};

export default LoginPage;
export default LoginPage;
23 changes: 22 additions & 1 deletion webui/src/styles/auth.css
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,25 @@ body .auth-page .nav-pills .nav-link:hover {
/* Direct style override for nav-pills */
.auth-page .nav-pills .nav-link.active {
background-color: var(--success) !important;
}
}

.login-container {
height: 100vh;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is vh?
Do we use it anywhere else?

Copy link
Contributor Author

@Annaseli Annaseli Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed the login-container

overflow: hidden;
position: fixed;
top: 0;
left: 0;
width: 100%;
background-color: #f8f9fa;
}

.login-card {
max-width: 600px;
width: 90%;
}

.login-logo {
width: 170px;
height: 60px;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are wrong values:
The image itself is 359x82 (if I'm looking at the right one),
Plus, the image looks squashed horizontally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're right, updated it

filter: brightness(0) saturate(100%);
}
Loading