This is an automated email from the ASF dual-hosted git repository. lauraxia pushed a commit to branch simple-login-v1.1.0 in repository https://gitbox.apache.org/repos/asf/gravitino.git
commit bccb7b3866e3f787fd218c13939143e318e0ddcc Author: Qian Xia <[email protected]> AuthorDate: Wed Jan 7 18:55:20 2026 +0800 support simple type login --- web/web/src/app/login/components/DefaultLogin.js | 223 ++++++++++++--------- .../src/app/login/components/defaultLoginSchema.js | 48 +++++ web/web/src/app/rootLayout/Logout.js | 14 +- web/web/src/lib/auth/providers/generic.js | 8 + web/web/src/lib/provider/session.js | 33 ++- web/web/src/lib/store/auth/index.js | 57 ++++-- web/web/src/lib/utils/axios/index.js | 7 + 7 files changed, 266 insertions(+), 124 deletions(-) diff --git a/web/web/src/app/login/components/DefaultLogin.js b/web/web/src/app/login/components/DefaultLogin.js index fd5ff0debf..2ee598a291 100644 --- a/web/web/src/app/login/components/DefaultLogin.js +++ b/web/web/src/app/login/components/DefaultLogin.js @@ -20,41 +20,31 @@ 'use client' import { useRouter } from 'next/navigation' -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { Grid, Button, Typography, TextField, FormControl, FormHelperText } from '@mui/material' -import * as yup from 'yup' import { useForm, Controller } from 'react-hook-form' import { yupResolver } from '@hookform/resolvers/yup' import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore' -import { loginAction, setIntervalIdAction, clearIntervalId } from '@/lib/store/auth' - -const defaultValues = { - grant_type: 'client_credentials', - client_id: '', - client_secret: '', - scope: '' -} - -const schema = yup.object().shape({ - grant_type: yup.string().required(), - client_id: yup.string().required(), - client_secret: yup.string().required(), - scope: yup.string().required() -}) +import { loginAction, setIntervalIdAction, clearIntervalId, setAuthUser } from '@/lib/store/auth' +import { DEFAULT_LOGIN_DEFAULT_VALUES, createDefaultLoginSchema } from './defaultLoginSchema' function DefaultLogin() { const router = useRouter() const dispatch = useAppDispatch() const store = useAppSelector(state => state.auth) + const isSimpleAuth = store.authType === 'simple' && store.anthEnable + + const schema = useMemo(() => createDefaultLoginSchema({ isSimpleAuth }), [isSimpleAuth]) + const { control, handleSubmit, reset, formState: { errors } } = useForm({ - defaultValues: Object.assign({}, defaultValues), + defaultValues: Object.assign({}, DEFAULT_LOGIN_DEFAULT_VALUES), mode: 'onChange', resolver: yupResolver(schema) }) @@ -66,8 +56,13 @@ function DefaultLogin() { }, [store.intervalId]) const onSubmit = async data => { - await dispatch(loginAction({ params: data, router })) - await dispatch(setIntervalIdAction()) + if (isSimpleAuth) { + await dispatch(setAuthUser({ name: String(data.username || '').trim(), type: 'user' })) + router.push('/metalakes') + } else { + await dispatch(loginAction({ params: data, router })) + await dispatch(setIntervalIdAction()) + } reset({ ...data }) } @@ -78,86 +73,120 @@ function DefaultLogin() { return ( <form autoComplete='off' onSubmit={handleSubmit(onSubmit, onError)}> - <Grid item xs={12} sx={{ mt: 4 }}> - <FormControl fullWidth> - <Controller - name='grant_type' - control={control} - rules={{ required: true }} - render={({ field: { value, onChange } }) => ( - <TextField - value={value} - label='Grant Type' - disabled - onChange={onChange} - placeholder='' - error={Boolean(errors.grant_type)} - /> + {isSimpleAuth ? ( + <Grid item xs={12} sx={{ mt: 4 }}> + <FormControl fullWidth> + <Controller + name='username' + control={control} + rules={{ required: true }} + render={({ field: { value, onChange } }) => ( + <TextField + value={value} + label='Username' + onChange={onChange} + placeholder='' + error={Boolean(errors.username)} + /> + )} + /> + {errors.username && ( + <FormHelperText className={'twc-text-error-main'}>{errors.username.message}</FormHelperText> )} - /> - {errors.grant_type && ( - <FormHelperText className={'twc-text-error-main'}>{errors.grant_type.message}</FormHelperText> - )} - </FormControl> - </Grid> - - <Grid item xs={12} sx={{ mt: 4 }}> - <FormControl fullWidth> - <Controller - name='client_id' - control={control} - rules={{ required: true }} - render={({ field: { value, onChange } }) => ( - <TextField - value={value} - label='Client ID' - onChange={onChange} - placeholder='' - error={Boolean(errors.client_id)} + </FormControl> + </Grid> + ) : ( + <> + <Grid item xs={12} sx={{ mt: 4 }}> + <FormControl fullWidth> + <Controller + name='grant_type' + control={control} + rules={{ required: true }} + render={({ field: { value, onChange } }) => ( + <TextField + value={value} + label='Grant Type' + disabled + onChange={onChange} + placeholder='' + error={Boolean(errors.grant_type)} + /> + )} /> - )} - /> - {errors.client_id && ( - <FormHelperText className={'twc-text-error-main'}>{errors.client_id.message}</FormHelperText> - )} - </FormControl> - </Grid> - - <Grid item xs={12} sx={{ mt: 4 }}> - <FormControl fullWidth> - <Controller - name='client_secret' - control={control} - rules={{ required: true }} - render={({ field: { value, onChange } }) => ( - <TextField - value={value} - label='Client Secret' - onChange={onChange} - placeholder='' - error={Boolean(errors.client_secret)} + {errors.grant_type && ( + <FormHelperText className={'twc-text-error-main'}>{errors.grant_type.message}</FormHelperText> + )} + </FormControl> + </Grid> + + <Grid item xs={12} sx={{ mt: 4 }}> + <FormControl fullWidth> + <Controller + name='client_id' + control={control} + rules={{ required: true }} + render={({ field: { value, onChange } }) => ( + <TextField + value={value} + label='Client ID' + onChange={onChange} + placeholder='' + error={Boolean(errors.client_id)} + /> + )} /> - )} - /> - {errors.client_secret && ( - <FormHelperText className={'twc-text-error-main'}>{errors.client_secret.message}</FormHelperText> - )} - </FormControl> - </Grid> - - <Grid item xs={12} sx={{ mt: 4 }}> - <FormControl fullWidth> - <Controller - name='scope' - control={control} - rules={{ required: true }} - render={({ field: { value, onChange } }) => ( - <TextField value={value} label='Scope' onChange={onChange} placeholder='' error={Boolean(errors.scope)} /> - )} - /> - {errors.scope && <FormHelperText className={'twc-text-error-main'}>{errors.scope.message}</FormHelperText>} - </FormControl> - </Grid> + {errors.client_id && ( + <FormHelperText className={'twc-text-error-main'}>{errors.client_id.message}</FormHelperText> + )} + </FormControl> + </Grid> + + <Grid item xs={12} sx={{ mt: 4 }}> + <FormControl fullWidth> + <Controller + name='client_secret' + control={control} + rules={{ required: true }} + render={({ field: { value, onChange } }) => ( + <TextField + value={value} + label='Client Secret' + onChange={onChange} + placeholder='' + error={Boolean(errors.client_secret)} + /> + )} + /> + {errors.client_secret && ( + <FormHelperText className={'twc-text-error-main'}>{errors.client_secret.message}</FormHelperText> + )} + </FormControl> + </Grid> + + <Grid item xs={12} sx={{ mt: 4 }}> + <FormControl fullWidth> + <Controller + name='scope' + control={control} + rules={{ required: true }} + render={({ field: { value, onChange } }) => ( + <TextField + value={value} + label='Scope' + onChange={onChange} + placeholder='' + error={Boolean(errors.scope)} + /> + )} + /> + {errors.scope && ( + <FormHelperText className={'twc-text-error-main'}>{errors.scope.message}</FormHelperText> + )} + </FormControl> + </Grid> + </> + )} <Button fullWidth size='large' type='submit' variant='contained' sx={{ mb: 7, mt: 12 }}> Login diff --git a/web/web/src/app/login/components/defaultLoginSchema.js b/web/web/src/app/login/components/defaultLoginSchema.js new file mode 100644 index 0000000000..43874d831f --- /dev/null +++ b/web/web/src/app/login/components/defaultLoginSchema.js @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as yup from 'yup' + +export const DEFAULT_LOGIN_DEFAULT_VALUES = { + grant_type: 'client_credentials', + client_id: '', + client_secret: '', + scope: '', + username: '' +} + +export function createDefaultLoginSchema({ isSimpleAuth }) { + if (isSimpleAuth) { + return yup.object().shape({ + username: yup.string().trim().required('Username is required'), + grant_type: yup.string().notRequired(), + client_id: yup.string().notRequired(), + client_secret: yup.string().notRequired(), + scope: yup.string().notRequired() + }) + } + + return yup.object().shape({ + grant_type: yup.string().required(), + client_id: yup.string().required(), + client_secret: yup.string().required(), + scope: yup.string().required(), + username: yup.string().notRequired() + }) +} diff --git a/web/web/src/app/rootLayout/Logout.js b/web/web/src/app/rootLayout/Logout.js index 1cca5ee49b..81803be302 100644 --- a/web/web/src/app/rootLayout/Logout.js +++ b/web/web/src/app/rootLayout/Logout.js @@ -22,7 +22,7 @@ import { useRouter } from 'next/navigation' import { useState, useEffect } from 'react' -import { Box, IconButton } from '@mui/material' +import { Box, IconButton, Tooltip } from '@mui/material' import Icon from '@/components/Icon' import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore' @@ -34,6 +34,8 @@ const LogoutButton = () => { const dispatch = useAppDispatch() const authStore = useAppSelector(state => state.auth) const [showLogoutButton, setShowLogoutButton] = useState(false) + const authEnabled = authStore.anthEnable === true || authStore.anthEnable === 'true' + const userName = authStore.authUser?.name useEffect(() => { const checkAuthStatus = async () => { @@ -68,10 +70,12 @@ const LogoutButton = () => { return ( <Box> - {showLogoutButton ? ( - <IconButton onClick={handleLogout}> - <Icon icon={'bx:exit'} /> - </IconButton> + {(authEnabled && userName) || showLogoutButton ? ( + <Tooltip title={userName || ''} disableHoverListener={!userName}> + <IconButton onClick={handleLogout}> + <Icon icon={'bx:exit'} /> + </IconButton> + </Tooltip> ) : null} </Box> ) diff --git a/web/web/src/lib/auth/providers/generic.js b/web/web/src/lib/auth/providers/generic.js index b235e94244..54f617e32c 100644 --- a/web/web/src/lib/auth/providers/generic.js +++ b/web/web/src/lib/auth/providers/generic.js @@ -53,6 +53,14 @@ export class GenericOAuthProvider extends BaseOAuthProvider { return !!token } + /** + * Get current user profile + */ + async getUserProfile() { + // Generic OAuth provider doesn't have user profile, default to a test user + return this.getAccessToken() ? { name: 'test' } : null + } + /** * Clear authentication data */ diff --git a/web/web/src/lib/provider/session.js b/web/web/src/lib/provider/session.js index 62569022f9..4ab33bfb6a 100644 --- a/web/web/src/lib/provider/session.js +++ b/web/web/src/lib/provider/session.js @@ -29,7 +29,7 @@ import { initialVersion, fetchGitHubInfo } from '@/lib/store/sys' import { oauthProviderFactory } from '@/lib/auth/providers/factory' import { to } from '../utils' -import { getAuthConfigs, setAuthToken } from '../store/auth' +import { getAuthConfigs, setAuthToken, setAuthUser } from '../store/auth' import { useIdle } from 'react-use' @@ -86,14 +86,28 @@ const AuthProvider = ({ children }) => { const initAuth = async () => { const [authConfigsErr, resAuthConfigs] = await to(dispatch(getAuthConfigs())) const authType = resAuthConfigs?.payload?.authType + const anthEnable = resAuthConfigs?.payload?.anthEnable // Always fetch GitHub info since it's a public API call dispatch(fetchGitHubInfo()) if (authType === 'simple') { dispatch(initialVersion()) - goToMetalakeListPage() + const sessionUser = typeof window !== 'undefined' && JSON.parse(window.sessionStorage.getItem('simpleAuthUser')) + if (anthEnable && !sessionUser) { + router.push('/login') + } else { + dispatch(setAuthUser(sessionUser)) + goToMetalakeListPage() + } } else if (authType === 'oauth') { + let provider = null + try { + provider = await oauthProviderFactory.getProvider() + } catch (e) { + provider = null + } + const tokenToUse = await oauthProviderFactory.getAccessToken() // Update local token state @@ -101,6 +115,21 @@ const AuthProvider = ({ children }) => { if (tokenToUse) { dispatch(setAuthToken(tokenToUse)) + + // Best-effort: hydrate auth user from OAuth provider profile. + // Do not block navigation if the profile cannot be loaded. + try { + const profile = provider?.getUserProfile ? await provider.getUserProfile() : null + + const displayName = + profile?.preferred_username || profile?.name || profile?.email || profile?.sub || profile?.id + if (displayName) { + dispatch(setAuthUser({ name: String(displayName), type: 'user', profile })) + } + } catch (e) { + // Ignore profile errors + } + dispatch(initialVersion()) goToMetalakeListPage() } else { diff --git a/web/web/src/lib/store/auth/index.js b/web/web/src/lib/store/auth/index.js index cce630e5e7..0e5e3739bc 100644 --- a/web/web/src/lib/store/auth/index.js +++ b/web/web/src/lib/store/auth/index.js @@ -32,6 +32,7 @@ const devOauthUrl = process.env.NEXT_PUBLIC_OAUTH_PATH export const getAuthConfigs = createAsyncThunk('auth/getAuthConfigs', async () => { let oauthUrl = null let authType = null + let anthEnable = null const [err, res] = await to(getAuthConfigsApi()) if (err || !res) { @@ -42,10 +43,11 @@ export const getAuthConfigs = createAsyncThunk('auth/getAuthConfigs', async () = // ** get the first authenticator from the response. response example: "[simple, oauth]" authType = res['gravitino.authenticators'][0].trim() + anthEnable = res['gravitino.authorization.enable'] localStorage.setItem('oauthUrl', oauthUrl) - return { oauthUrl, authType } + return { oauthUrl, authType, anthEnable } }) export const refreshToken = createAsyncThunk('auth/refreshToken', async (data, { getState, dispatch }) => { @@ -93,25 +95,29 @@ export const loginAction = createAsyncThunk('auth/loginAction', async ({ params, export const logoutAction = createAsyncThunk('auth/logoutAction', async ({ router }, { getState, dispatch }) => { // Clear provider authentication data first - try { - const provider = await oauthProviderFactory.getProvider() - if (provider) { - await provider.clearAuthData() - console.log('[Logout Action] Provider cleanup completed') + if (getState().auth.authType === 'oauth') { + try { + const provider = await oauthProviderFactory.getProvider() + if (provider) { + await provider.clearAuthData() + console.log('[Logout Action] Provider cleanup completed') + } + } catch (error) { + console.warn('[Logout Action] Provider cleanup failed:', error) } - } catch (error) { - console.warn('[Logout Action] Provider cleanup failed:', error) - } - - // Clear legacy auth tokens - localStorage.removeItem('accessToken') - localStorage.removeItem('authParams') - localStorage.removeItem('expiredIn') - localStorage.removeItem('isIdle') - localStorage.removeItem('version') - dispatch(clearIntervalId()) - dispatch(setAuthToken('')) + // Clear legacy auth tokens + localStorage.removeItem('accessToken') + localStorage.removeItem('authParams') + localStorage.removeItem('expiredIn') + localStorage.removeItem('isIdle') + localStorage.removeItem('version') + + dispatch(clearIntervalId()) + dispatch(setAuthToken('')) + } else { + dispatch(setAuthUser(null)) + } await router.push('/login') return { token: null } @@ -149,7 +155,9 @@ export const authSlice = createSlice({ authToken: null, authParams: null, expiredIn: null, - intervalId: null + intervalId: null, + anthEnable: null, + authUser: null }, reducers: { setIntervalId(state, action) { @@ -169,12 +177,21 @@ export const authSlice = createSlice({ }, setExpiredIn(state, action) { state.expiredIn = action.payload + }, + setAuthUser(state, action) { + if (action.payload) { + sessionStorage.setItem('simpleAuthUser', JSON.stringify(action.payload)) + } else { + sessionStorage.removeItem('simpleAuthUser') + } + state.authUser = action.payload } }, extraReducers: builder => { builder.addCase(getAuthConfigs.fulfilled, (state, action) => { state.oauthUrl = action.payload.oauthUrl state.authType = action.payload.authType + state.anthEnable = action.payload.anthEnable }) builder.addCase(refreshToken.fulfilled, (state, action) => { localStorage.setItem('accessToken', action.payload.token) @@ -186,6 +203,6 @@ export const authSlice = createSlice({ } }) -export const { setAuthToken, setAuthParams, setExpiredIn, clearIntervalId } = authSlice.actions +export const { setAuthToken, setAuthParams, setExpiredIn, clearIntervalId, setAuthUser } = authSlice.actions export default authSlice.reducer diff --git a/web/web/src/lib/utils/axios/index.js b/web/web/src/lib/utils/axios/index.js index a0710bb08b..d9df97f88d 100644 --- a/web/web/src/lib/utils/axios/index.js +++ b/web/web/src/lib/utils/axios/index.js @@ -187,6 +187,13 @@ const transform = { if (token && config?.requestOptions?.withToken !== false) { // ** jwt token config.headers.Authorization = options.authenticationScheme ? `${options.authenticationScheme} ${token}` : token + } else if (window.sessionStorage.getItem('simpleAuthUser')) { + // Simple auth fallback + const simpleAuthToken = window.sessionStorage.getItem('simpleAuthToken') + const user = JSON.parse(window.sessionStorage.getItem('simpleAuthUser'))?.name + if (user) { + config.headers.Authorization = `Basic ${Buffer.from(user || '').toString('base64')}` + } } } catch (error) { console.warn('Failed to get access token:', error)
