diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 07a2aa504..f2dfc9573 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,7 +11,7 @@ # 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. -FROM node:18.15-alpine As base +FROM node:20.4.0-alpine As base ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" @@ -60,7 +60,7 @@ ARG path RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm --offline --filter=$name install && pnpm --filter=$name run build # Production image, copy all the files and run next -FROM node:current-alpine AS runner +FROM node:20.4.0-alpine AS runner WORKDIR /app ENV NODE_ENV production @@ -84,7 +84,7 @@ fi USER nextjs # You only need to copy next.config.js if you are NOT using the default configuration -# COPY --from=builder /app/desktop/next.config.js ./ +COPY --from=builder /app/$path/next.config.js ./$path/next.config.js COPY --from=builder /app/$path/public ./$path/public COPY --from=builder --chown=nextjs:nodejs /app/$path/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/$path/.next/static ./$path/.next/static diff --git a/frontend/desktop/Dockerfile b/frontend/desktop/Dockerfile index 32e15899d..e59e330f8 100644 --- a/frontend/desktop/Dockerfile +++ b/frontend/desktop/Dockerfile @@ -13,7 +13,7 @@ # limitations under the License. # Install dependencies only when needed -FROM node:current-alpine AS deps +FROM node:20.4.0-alpine AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. RUN apk add --no-cache libc6-compat && npm install -g pnpm WORKDIR /app diff --git a/frontend/desktop/next.config.js b/frontend/desktop/next.config.js index 2fb954dd7..69599c887 100644 --- a/frontend/desktop/next.config.js +++ b/frontend/desktop/next.config.js @@ -15,6 +15,7 @@ const nextConfig = withPWA({ reactStrictMode: false, swcMinify: isProduction, output: 'standalone', + transpilePackages: ['@sealos/ui'], experimental: { outputFileTracingRoot: path.join(__dirname, '../') } diff --git a/frontend/desktop/package.json b/frontend/desktop/package.json index 36d370b42..094dc83ed 100644 --- a/frontend/desktop/package.json +++ b/frontend/desktop/package.json @@ -8,12 +8,18 @@ "start": "next start", "lint": "next lint", "test:e2e": "jest --testPathPattern=/e2e/ --runInBand", + "test:e2e-namespace": "jest --testPathPattern=/e2e/namespace/ --runInBand", + "test:e2e-auth": "jest --testPathPattern=/e2e/auth/ --runInBand", "test:ci": "jest --runInBand" }, "dependencies": { "@alicloud/dysmsapi20170525": "^2.0.24", "@alicloud/openapi-client": "^0.4.6", "@alicloud/tea-util": "^1.4.7", + "@chakra-ui/next-js": "^2.1.5", + "@chakra-ui/react": "^2.8.1", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", "@kubernetes/client-node": "^0.18.1", "@sealos/ui": "workspace:^", "@tanstack/react-query": "^4.35.3", @@ -23,6 +29,7 @@ "dayjs": "^1.11.10", "eslint": "8.38.0", "eslint-config-next": "13.3.0", + "framer-motion": "^10.16.4", "i18next": "^22.5.1", "immer": "^10.0.2", "js-cookie": "^3.0.5", diff --git a/frontend/desktop/public/locales/en/common.json b/frontend/desktop/public/locales/en/common.json index 436a19a89..8b4713fe8 100644 --- a/frontend/desktop/public/locales/en/common.json +++ b/frontend/desktop/public/locales/en/common.json @@ -21,6 +21,14 @@ "password tips": "Password must be 8 characters or more", "username tips": "Username must be 3-16 characters, including letters, numbers", "Password": "Password", + "change": "change", + "changePassword": "Change Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "passwordMismatch": "Passwords do not match", + "passwordChangeSuccess": "Password changed successfully", + "currentPasswordRequired": "Current password cannot be empty", "Verify password": "Verify password", "verify code tips": "6-digit Verification Code", "phone number tips": "Phone Number", diff --git a/frontend/desktop/public/locales/zh/common.json b/frontend/desktop/public/locales/zh/common.json index acf40eb1d..e039f8666 100644 --- a/frontend/desktop/public/locales/zh/common.json +++ b/frontend/desktop/public/locales/zh/common.json @@ -17,7 +17,17 @@ "phone number tips": "手机号码", "password tips": "密码为8位以上字符", "username tips": "用户名为3-16位的英文或数字的字符", + "currentPasswordRequired": "当前密码不能为空", + "changePassword": "修改密码", + "currentPassword": "当前密码", + "newPassword": "新密码", + "confirmNewPassword": "确认新密码", + "passwordMismatch": "密码不一致", + "passwordChangeSuccess": "密码修改成功", + "passwordMismatch": "密码不一致", + "newPassword": "新密码", "Verify password": "确认密码", + "change": "修改", "Invalid username or password": "用户名或密码错误", "Invalid phone number": "无效的手机号码", "Invalid verification code": "无效的验证码", diff --git a/frontend/desktop/src/__tests__/api/e2e/auth/password.test.ts b/frontend/desktop/src/__tests__/api/e2e/auth/password.test.ts new file mode 100644 index 000000000..43d801edc --- /dev/null +++ b/frontend/desktop/src/__tests__/api/e2e/auth/password.test.ts @@ -0,0 +1,67 @@ +import { Session } from 'sealos-desktop-sdk/*'; +import * as k8s from '@kubernetes/client-node'; +import { _passwordLoginRequest, _passwordModifyRequest } from '@/api/auth'; +import { _setAuth, cleanDb, cleanK8s } from '@/__tests__/api/tools'; +import { _createRequest } from '@/api/namespace'; +import request from '@/__tests__/api/request'; +import { Db, MongoClient } from 'mongodb'; +describe('Password', () => { + let session: Session; + const createRequest = _createRequest(request); + const modifyRequest = _passwordModifyRequest(request); + let db: Db; + let connection: MongoClient; + const setAuth = _setAuth(request); + beforeAll(async () => { + //@ts-ignore + const uri = process.env.MONGODB_URI as string; + // console.log('MONGODB_URI', uri) + connection = new MongoClient(uri); + await connection.connect(); + db = connection.db(); + const kc = new k8s.KubeConfig(); + await cleanK8s(kc, db); + await cleanDb(db); + const res = await _passwordLoginRequest(request)({ user: 'createTest', password: 'testtest' }); + expect(res.data?.user).toBeDefined(); + session = res.data!; + setAuth(session); + console.log('create,', session.user); + }, 100000); + afterAll(async () => { + await connection.close(); + }); + it('modify: null param', async () => { + // @ts-ignore + const res = await modifyRequest({}); + // 没参数 + expect(res.code).toBe(400); + /** @ts-ignore **/ + const res2 = await modifyRequest({ oldPassword: 'testtest' }); + // 没参数 + expect(res2.code).toBe(400); + /** @ts-ignore **/ + const res3 = await modifyRequest({ newPassword: 'zxafafa' }); + // 没参数 + expect(res3.code).toBe(400); + }); + + it('error oldPassword', async () => { + const res = await modifyRequest({ oldPassword: 'test', newPassword: 'testtest' }); + expect(res.code).toBe(409); + }); + + it('same password', async () => { + const res = await modifyRequest({ oldPassword: 'testtest', newPassword: 'testtest' }); + expect(res.code).toBe(409); + }); + it('right password', async () => { + const res = await modifyRequest({ oldPassword: 'testtest', newPassword: 'testtest2' }); + expect(res.code).toBe(200); + const res2 = await _passwordLoginRequest(request)({ + user: 'createTest', + password: 'testtest2' + }); + expect(res2.code).toBe(200); + }); +}); diff --git a/frontend/desktop/src/api/auth.ts b/frontend/desktop/src/api/auth.ts index 8953d8497..057724f30 100644 --- a/frontend/desktop/src/api/auth.ts +++ b/frontend/desktop/src/api/auth.ts @@ -4,13 +4,15 @@ import { TUserExist } from '@/types/user'; import { ApiResp } from '@/types'; import { AxiosInstance } from 'axios'; -export const _passwordExistRequest = (request: AxiosInstance) => (user: string) => - request.post, { user: string }>('/api/auth/password/exist', { - user - }); +export const _passwordExistRequest = (request: AxiosInstance) => (data: { user: string }) => + request.post>('/api/auth/password/exist', data); export const _passwordLoginRequest = (request: AxiosInstance) => (data: { user: string; password: string }) => request.post>('/api/auth/password', data); +export const _passwordModifyRequest = + (request: AxiosInstance) => (data: { oldPassword: string; newPassword: string }) => + request.post>('/api/auth/password/modify', data); export const passwordExistRequest = _passwordExistRequest(request); export const passwordLoginRequest = _passwordLoginRequest(request); +export const passwordModifyRequest = _passwordModifyRequest(request); diff --git a/frontend/desktop/src/components/account/PasswordModify.tsx b/frontend/desktop/src/components/account/PasswordModify.tsx new file mode 100644 index 000000000..817270050 --- /dev/null +++ b/frontend/desktop/src/components/account/PasswordModify.tsx @@ -0,0 +1,190 @@ +import { + Button, + Flex, + FlexProps, + Image, + Input, + InputGroup, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Spinner, + Stack, + useDisclosure +} from '@chakra-ui/react'; +import { useMutation } from '@tanstack/react-query'; +import { useCustomToast } from '@/hooks/useCustomToast'; +import { ApiResp } from '@/types'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { strongPassword } from '@/utils/crypto'; +import { passwordModifyRequest } from '@/api/auth'; +export default function PasswordModify(props: FlexProps) { + const { onOpen, isOpen, onClose } = useDisclosure(); + const { t } = useTranslation(); + const { toast } = useCustomToast({ status: 'error' }); + const mutation = useMutation(passwordModifyRequest, { + onSuccess(data) { + if (data.code === 200) { + toast({ + status: 'success', + title: t('passwordChangeSuccess') + }); + onClose(); + } + }, + onError(error) { + toast({ title: (error as ApiResp).message }); + } + }); + const { register, handleSubmit, reset } = useForm<{ + oldPassword: string; + newPassword: string; + againPassword: string; + }>(); + return ( + <> + { + reset(); + onOpen(); + }} + > + {t('change')} + + + + + + {t('changePassword')} + {mutation.isLoading ? ( + + ) : ( + <> + +
{ + mutation.mutate({ oldPassword, newPassword }); + }, + (errors) => { + if (errors.oldPassword) return toast({ title: t('currentPasswordRequired') }); + if (errors.newPassword) return toast({ title: t('password tips') }); + if (errors.againPassword) return toast({ title: t('passwordMismatch') }); + } + )} + > + + + + + + + + + + + + +
+
+ + )} +
+
+ + ); +} diff --git a/frontend/desktop/src/components/account/index.tsx b/frontend/desktop/src/components/account/index.tsx index b095f7557..cc3129bdb 100644 --- a/frontend/desktop/src/components/account/index.tsx +++ b/frontend/desktop/src/components/account/index.tsx @@ -17,24 +17,25 @@ import { import { useMutation, useQuery } from '@tanstack/react-query'; import { useTranslation } from 'next-i18next'; import { useRouter } from 'next/router'; -import { useContext, useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import Iconfont from '../iconfont'; import useAppStore from '@/stores/app'; import { ApiResp } from '@/types'; import { formatMoney } from '@/utils/format'; -import { RechargeEnabledContext } from '@/pages'; import TeamCenter from '@/components/team/TeamCenter'; import NsList from '@/components/team/NsList'; import { nsListRequest, switchRequest } from '@/api/namespace'; import RightIcon from '../icons/RightArrow'; import { NSType } from '@/types/team'; +import PasswordModify from './PasswordModify'; +import { useGlobalStore } from '@/stores/global'; const NsMenu = () => { const { t } = useTranslation(); const session = useSessionStore((s) => s.session); const setSession = useSessionStore((s) => s.setSession); - const { ns_uid, k8s_username } = session.user; + const { ns_uid } = session.user; const router = useRouter(); const mutation = useMutation({ mutationFn: switchRequest, @@ -83,7 +84,7 @@ const NsMenu = () => { transition={'0.3s'} p="4px" > - + {namespace?.nstype === NSType.Private ? t('Default Team') : namespace?.teamName} @@ -112,7 +113,6 @@ const NsMenu = () => { }; export default function Index({ disclosure }: { disclosure: UseDisclosureReturn }) { const router = useRouter(); - const rechargeEnabled = useContext(RechargeEnabledContext); const { t } = useTranslation(); const { delSession, getSession } = useSessionStore(); const { user, kubeconfig } = getSession(); @@ -133,7 +133,6 @@ export default function Index({ disclosure }: { disclosure: UseDisclosureReturn '/api/account/getAmount' ) }); - const balance = useMemo(() => { let real_balance = data?.data?.balance || 0; if (data?.data?.deductionBalance) { @@ -146,9 +145,17 @@ export default function Index({ disclosure }: { disclosure: UseDisclosureReturn delSession(); router.replace('/signin'); }; + const needPassword = useGlobalStore((s) => s.needPassword); + const rechargeEnabled = useGlobalStore((s) => s.rechargeEnabled); return disclosure.isOpen ? ( <> - + - + {t('Manage Team')} - + {needPassword && ( + + {t('changePassword')} + + + )} + {t('Balance')}: {formatMoney(balance).toFixed(2)} @@ -217,14 +238,15 @@ export default function Index({ disclosure }: { disclosure: UseDisclosureReturn color={'#219BF4'} fontWeight="500" fontSize="12px" + cursor={'pointer'} > {t('Charge')} )} { - - kubeconfig + + kubeconfig download('kubeconfig.yaml', kubeconfig)} > copyData(kubeconfig)} cursor={'pointer'} > diff --git a/frontend/desktop/src/components/desktop_content/index.tsx b/frontend/desktop/src/components/desktop_content/index.tsx index 4da3b39b4..d12c96769 100644 --- a/frontend/desktop/src/components/desktop_content/index.tsx +++ b/frontend/desktop/src/components/desktop_content/index.tsx @@ -121,7 +121,7 @@ export default function DesktopContent(props: any) { textAlign={'center'} mt="8px" color={'#FFFFFF'} - fontSize={'10px'} + fontSize={'13px'} lineHeight={'16px'} > {item?.i18n?.[i18n?.language]?.name diff --git a/frontend/desktop/src/components/signin/auth/usePassword.tsx b/frontend/desktop/src/components/signin/auth/usePassword.tsx index 6346b3225..eca6eecfa 100644 --- a/frontend/desktop/src/components/signin/auth/usePassword.tsx +++ b/frontend/desktop/src/components/signin/auth/usePassword.tsx @@ -1,3 +1,4 @@ +import { passwordExistRequest, passwordLoginRequest } from '@/api/auth'; import request from '@/services/request'; import useSessionStore from '@/stores/session'; import { ApiResp, Session } from '@/types'; @@ -43,15 +44,9 @@ export default function usePassword({ if (data?.username && data?.password) { try { setIsLoading(true); - const result = await request.post, { user: string }>( - '/api/auth/password/exist', - { - user: data.username - } - ); - + const result = await passwordExistRequest({ user: data.username }); if (result?.code === 200) { - const result = await request.post>('/api/auth/password', { + const result = await passwordLoginRequest({ user: data.username, password: data.password }); diff --git a/frontend/desktop/src/components/user_menu/index.tsx b/frontend/desktop/src/components/user_menu/index.tsx index 8eac64081..0e064f4aa 100644 --- a/frontend/desktop/src/components/user_menu/index.tsx +++ b/frontend/desktop/src/components/user_menu/index.tsx @@ -16,7 +16,6 @@ export default function Index(props: { userMenuStyleProps?: FlexProps }) { const [notificationAmount, setNotificationAmount] = useState(0); const accountDisclosure = useDisclosure(); const showDisclosure = useDisclosure(); - const switchLangDisclosure = useDisclosure(); const userInfo = useSessionStore((state) => state.getSession()); if (!userInfo) return null; @@ -75,7 +74,7 @@ export default function Index(props: { userMenuStyleProps?: FlexProps }) { background={'rgba(244, 246, 248, 0.7)'} boxShadow={'0px 1.2px 2.3px rgba(0, 0, 0, 0.2)'} /> - {buttonList.map((item, index) => ( + {buttonList.map((item) => ( item.userId === payload.user.uid && payload.user.k8s_username === item.k8s_username - ); // if ( - if (!userInNs) return jsonRes(res, { code: 403, message: 'you are not in this namespace' }); - const kubeconfig = jsYaml.dump( - JSON.parse(switchNamespace(payload.kc, userInNs?.namespace?.id || '').exportConfig()) ); + if (!userInNs) return jsonRes(res, { code: 403, message: 'you are not in this namespace' }); + + const oldKc = jsYaml.load(payload.kcRaw); + // @ts-ignore + oldKc.contexts[0].context.namespace = userInNs?.namespace?.id || ''; + const kubeconfig = jsYaml.dump(oldKc); const user = { ns_uid: userInNs.namespaceId, nsid: userInNs.namespace.id, @@ -48,6 +49,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) message: 'Successfully' }); } catch (e) { + console.log(e); jsonRes(res, { code: 500, message: 'fail to switch namespace' }); } } diff --git a/frontend/desktop/src/pages/api/auth/password/modify.ts b/frontend/desktop/src/pages/api/auth/password/modify.ts new file mode 100644 index 000000000..facd93951 --- /dev/null +++ b/frontend/desktop/src/pages/api/auth/password/modify.ts @@ -0,0 +1,64 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { jsonRes } from '@/services/backend/response'; +import { Session } from '@/types/session'; +import { hashPassword, strongPassword, verifyPassword } from '@/utils/crypto'; +import { enablePassword } from '@/services/enable'; +import { authSession } from '@/services/backend/auth'; +import { queryUser, updateUser } from '@/services/backend/db/user'; +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + try { + if (!enablePassword()) { + throw new Error('PASSWORD_SALT is not defined'); + } + const payload = await authSession(req.headers); + if (!payload) return jsonRes(res, { code: 401, message: 'token verify error' }); + const uid = payload.user.uid; + const { oldPassword, newPassword } = req.body; + if (!oldPassword) return jsonRes(res, { code: 400, message: 'oldPassword is required' }); + if (!newPassword) return jsonRes(res, { code: 400, message: 'newPassword is required' }); + const _user = await queryUser({ id: uid, provider: 'uid' }); + if (!_user) return jsonRes(res, { code: 404, message: 'user is not founded' }); + if (!_user.password) { + return jsonRes(res, { code: 409, message: 'Please login by password' }); + } + if ( + !oldPassword || + !verifyPassword(oldPassword, _user.password) || + oldPassword === newPassword + ) { + return jsonRes(res, { code: 409, message: 'password error' }); + } + if (!strongPassword(newPassword)) { + return jsonRes(res, { + message: + 'Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number and one special character', + code: 400 + }); + } + const updateRes = await updateUser({ + id: uid, + provider: 'uid', + data: { password: hashPassword(newPassword) } + }); + if (!updateRes.acknowledged) throw new Error('modify password error'); + console.log( + 'modify', + updateRes, + oldPassword, + newPassword, + 'hash', + hashPassword(oldPassword), + hashPassword(newPassword) + ); + return jsonRes(res, { + code: 200, + message: 'Successfully!' + }); + } catch (err) { + console.log(err); + return jsonRes(res, { + message: 'Failed to authenticate with password', + code: 500 + }); + } +} diff --git a/frontend/desktop/src/pages/api/platform/getEnv.ts b/frontend/desktop/src/pages/api/platform/getEnv.ts index 455c83e8e..108f42b41 100644 --- a/frontend/desktop/src/pages/api/platform/getEnv.ts +++ b/frontend/desktop/src/pages/api/platform/getEnv.ts @@ -8,7 +8,8 @@ import { enableGoogle, enableStripe, enableWechatRecharge, - enableLicense + enableLicense, + enableRecharge } from '@/services/enable'; export default async function handler(req: NextApiRequest, res: NextApiResponse) { @@ -26,7 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) const stripeEnabled = enableStripe(); const wechatEnabledRecharge = enableWechatRecharge(); const licenseEnabled = enableLicense(); - + const rechargeEnabled = enableRecharge(); jsonRes(res, { data: { SEALOS_CLOUD_DOMAIN: process.env.SEALOS_CLOUD_DOMAIN || 'cloud.sealos.io', @@ -43,6 +44,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) needGoogle, stripeEnabled, wechatEnabledRecharge, + rechargeEnabled, licenseEnabled } }); diff --git a/frontend/desktop/src/pages/index.tsx b/frontend/desktop/src/pages/index.tsx index 1b5dfc3ed..82e9dd669 100644 --- a/frontend/desktop/src/pages/index.tsx +++ b/frontend/desktop/src/pages/index.tsx @@ -4,9 +4,10 @@ import MoreApps from '@/components/more_apps'; import { enableRecharge } from '@/services/enable'; import request from '@/services/request'; import useAppStore from '@/stores/app'; +import { useGlobalStore } from '@/stores/global'; import useSessionStore from '@/stores/session'; import { ApiResp } from '@/types'; -import { SystemConfigType } from '@/types/system'; +import { SystemConfigType, SystemEnv } from '@/types/system'; import { parseOpenappQuery } from '@/utils/format'; import { compareFirstLanguages } from '@/utils/tools'; import { Box, useColorMode } from '@chakra-ui/react'; @@ -23,26 +24,23 @@ interface IMoreAppsContext { setShowMoreApps: (value: boolean) => void; } export const MoreAppsContext = createContext(null); -export const RechargeEnabledContext = createContext(false); -export default function Home({ - rechargeEnabled, - sealos_cloud_domain -}: { - rechargeEnabled: boolean; - sealos_cloud_domain: string; -}) { +export default function Home({ sealos_cloud_domain }: { sealos_cloud_domain: string }) { const router = useRouter(); const { isUserLogin, setSession } = useSessionStore(); const { colorMode, toggleColorMode } = useColorMode(); const init = useAppStore((state) => state.init); const setAutoLaunch = useAppStore((state) => state.setAutoLaunch); const cancelAutoLaunch = useAppStore((state) => state.cancelAutoLaunch); - const { data: systemConfig, refetch } = useQuery(['getSystemConfig'], () => request>('/api/system/getSystemConfig') ); - + const { data: platformEnv, isSuccess } = useQuery(['getPlatformEnv'], () => + request>('/api/platform/getEnv') + ); + const setEnv = useGlobalStore((s) => s.setEnv); + // @ts-ignore + if (isSuccess) Object.entries(platformEnv?.data!).forEach(([k, v]) => setEnv(k, v)); useEffect(() => { colorMode === 'dark' ? toggleColorMode() : null; }, [colorMode, toggleColorMode]); @@ -52,7 +50,6 @@ export default function Home({ const { query } = router; const is_login = isUserLogin(); const whitelistApps = ['system-fastdeploy']; - console.log('index'); if (!is_login) { const { appkey, appQuery } = parseOpenappQuery((query?.openapp as string) || ''); // sealos_inside=true internal call @@ -95,11 +92,9 @@ export default function Home({ return