diff --git a/label_studio/users/templates/users/user_account.html b/label_studio/users/templates/users/user_account.html
deleted file mode 100644
index c20eca96f139..000000000000
--- a/label_studio/users/templates/users/user_account.html
+++ /dev/null
@@ -1,374 +0,0 @@
-{% extends 'base.html' %}
-{% load static %}
-
-{% block head %}
-
-
-{% endblock %}
-
-{% block divider %}
-{% endblock %}
-
-{% block frontend_settings %}
- {
- breadcrumbs: [
- {
- title: "Account & Settings"
- }
- ],
- }
-{% endblock %}
-
-{% block content %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% block notifications %}
-
- {% endblock %}
-
-
-
-
-
-
-
-{% endblock %}
diff --git a/label_studio/users/views.py b/label_studio/users/views.py
index 77ea4edcb4c0..ea4b8e19d6a0 100644
--- a/label_studio/users/views.py
+++ b/label_studio/users/views.py
@@ -139,7 +139,7 @@ def user_account(request):
return redirect(reverse('main'))
form = forms.UserProfileForm(instance=user)
- token = Token.objects.get(user=user)
+ Token.objects.get(user=user)
if request.method == 'POST':
form = forms.UserProfileForm(request.POST, instance=user)
@@ -149,6 +149,5 @@ def user_account(request):
return render(
request,
- 'users/user_account.html',
- {'settings': settings, 'user': user, 'user_profile_form': form, 'token': token},
+ 'base.html',
)
diff --git a/web/apps/labelstudio/src/app/App.jsx b/web/apps/labelstudio/src/app/App.jsx
index 7eeb45e658de..b689f6620b53 100644
--- a/web/apps/labelstudio/src/app/App.jsx
+++ b/web/apps/labelstudio/src/app/App.jsx
@@ -19,6 +19,7 @@ import { RootPage } from "./RootPage";
import { FF_OPTIC_2, FF_UNSAVED_CHANGES, FF_PRODUCT_TOUR, isFF } from "../utils/feature-flags";
import { TourProvider } from "@humansignal/core";
import { ToastProvider, ToastViewport } from "@humansignal/ui";
+import { CurrentUserProvider } from "../providers/CurrentUser";
const baseURL = new URL(APP_SETTINGS.hostname || location.origin);
export const UNBLOCK_HISTORY_MESSAGE = "UNBLOCK_HISTORY";
@@ -62,6 +63,7 @@ const App = ({ content }) => {
-
+
+
{showNewsletterDot &&
}
diff --git a/web/apps/labelstudio/src/components/SidebarMenu/SidebarMenu.scss b/web/apps/labelstudio/src/components/SidebarMenu/SidebarMenu.scss
index 3bb4fa0e09c0..269fad0d028f 100644
--- a/web/apps/labelstudio/src/components/SidebarMenu/SidebarMenu.scss
+++ b/web/apps/labelstudio/src/components/SidebarMenu/SidebarMenu.scss
@@ -6,6 +6,19 @@
&__navigation {
width: calc(var(--menu-sidebar-width) + 1px);
display: flex;
+
+ & .main-menu {
+ background: var(--sand_100);
+
+ &__item{
+ background: var(--sand_100);
+
+ &_active{
+ background: var(--sand_200);
+ pointer-events: all;
+ }
+ }
+ }
}
&__content {
diff --git a/web/apps/labelstudio/src/components/Userpic/Userpic.jsx b/web/apps/labelstudio/src/components/Userpic/Userpic.jsx
index dbb8f05f91be..cc792ca63506 100644
--- a/web/apps/labelstudio/src/components/Userpic/Userpic.jsx
+++ b/web/apps/labelstudio/src/components/Userpic/Userpic.jsx
@@ -6,73 +6,79 @@ import "./Userpic.scss";
const FALLBACK_IMAGE =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
-export const Userpic = forwardRef(({ username, size, src, user, className, showUsername, style, ...rest }, ref) => {
- const imgRef = useRef();
- const [finalUsername, setFinalUsername] = useState(username);
- const [finalSrc, setFinalSrc] = useState(user?.avatar ?? src);
- const [imgVisible, setImgVisible] = useState(false);
- const [nameVisible, setNameVisible] = useState(true);
+export const Userpic = forwardRef(
+ ({ username, size, src, user, className, showUsername, isInProgress, style, ...rest }, ref) => {
+ const imgRef = useRef();
+ const [finalUsername, setFinalUsername] = useState(username);
+ const [finalSrc, setFinalSrc] = useState(user?.avatar ?? src);
+ const [imgVisible, setImgVisible] = useState(false);
+ const [nameVisible, setNameVisible] = useState(true);
- if (size) {
- style = Object.assign({ width: size, height: size, fontSize: size * 0.4 }, style);
- }
+ if (size) {
+ style = Object.assign({ width: size, height: size, fontSize: size * 0.4 }, style);
+ }
+
+ useEffect(() => {
+ if (isInProgress) {
+ setFinalSrc(null);
+ setImgVisible(false);
+ setNameVisible(true);
+ } else if (user) {
+ const { first_name, last_name, email, initials, username } = user;
- useEffect(() => {
- if (user) {
- const { first_name, last_name, email, initials, username } = user;
+ if (initials) {
+ setFinalUsername(initials);
+ } else if (username) {
+ setFinalUsername(username);
+ } else if (first_name && last_name) {
+ setFinalUsername(`${first_name[0]}${last_name[0]}`);
+ } else if (email) {
+ setFinalUsername(email.substring(0, 2));
+ }
- if (initials) {
- setFinalUsername(initials);
- } else if (username) {
+ if (user.avatar) setFinalSrc(user.avatar);
+ } else {
setFinalUsername(username);
- } else if (first_name && last_name) {
- setFinalUsername(`${first_name[0]}${last_name[0]}`);
- } else if (email) {
- setFinalUsername(email.substring(0, 2));
+ setFinalSrc(src);
}
+ }, [user, isInProgress]);
- if (user.avatar) setFinalSrc(user.avatar);
- } else {
- setFinalUsername(username);
- setFinalSrc(src);
- }
- }, [user]);
-
- const onImageLoaded = useCallback(() => {
- setImgVisible(true);
- if (finalSrc !== FALLBACK_IMAGE) setNameVisible(false);
- }, [finalSrc]);
+ const onImageLoaded = useCallback(() => {
+ setImgVisible(true);
+ if (finalSrc !== FALLBACK_IMAGE) setNameVisible(false);
+ }, [finalSrc]);
- const userpic = (
-
- setFinalSrc(FALLBACK_IMAGE)}
- />
- {nameVisible && (
-
- {(finalUsername ?? "").toUpperCase()}
-
- )}
-
- );
+ const userpic = (
+
+ setFinalSrc(FALLBACK_IMAGE)}
+ />
+ {nameVisible && (
+
+ {(finalUsername ?? "").toUpperCase()}
+
+ )}
+
+ );
- const userFullName = useMemo(() => {
- if (user?.first_name || user?.last_name) {
- return `${user?.first_name ?? ""} ${user?.last_name ?? ""}`.trim();
- }
- if (user?.email) {
- return user.email;
- }
- return username;
- }, [user, username]);
+ const userFullName = useMemo(() => {
+ if (user?.first_name || user?.last_name) {
+ return `${user?.first_name ?? ""} ${user?.last_name ?? ""}`.trim();
+ }
+ if (user?.email) {
+ return user.email;
+ }
+ return username;
+ }, [user, username]);
- return showUsername && userFullName ?
{userpic} : userpic;
-});
+ return showUsername && userFullName ?
{userpic} : userpic;
+ },
+);
Userpic.displayName = "Userpic";
diff --git a/web/apps/labelstudio/src/config/ApiConfig.js b/web/apps/labelstudio/src/config/ApiConfig.js
index e6323df48637..e24617262092 100644
--- a/web/apps/labelstudio/src/config/ApiConfig.js
+++ b/web/apps/labelstudio/src/config/ApiConfig.js
@@ -3,10 +3,14 @@ export const API_CONFIG = {
endpoints: {
// Users
users: "/users",
+ updateUser: "PATCH:/users/:pk",
+ updateUserAvatar: "POST:/users/:pk/avatar",
+ deleteUserAvatar: "DELETE:/users/:pk/avatar",
me: "/current-user/whoami",
// Organization
memberships: "/organizations/:pk/memberships",
+ userMemberships: "/organizations/:pk/memberships/:userPk",
inviteLink: "/invite",
resetInviteLink: "POST:/invite/reset-token",
diff --git a/web/apps/labelstudio/src/pages/index.js b/web/apps/labelstudio/src/pages/index.js
index 0c35f0e157f7..0009ba51da78 100644
--- a/web/apps/labelstudio/src/pages/index.js
+++ b/web/apps/labelstudio/src/pages/index.js
@@ -1,5 +1,6 @@
import { ProjectsPage } from "./Projects/Projects";
import { OrganizationPage } from "./Organization";
import { ModelsPage } from "./Organization/Models/ModelsPage";
+import { AccountSettingsPage } from "@humansignal/core";
-export const Pages = [ProjectsPage, OrganizationPage, ModelsPage];
+export const Pages = [ProjectsPage, OrganizationPage, ModelsPage, AccountSettingsPage];
diff --git a/web/apps/labelstudio/src/providers/CurrentUser.d.ts b/web/apps/labelstudio/src/providers/CurrentUser.d.ts
index 60ba7025a625..40c80774b405 100644
--- a/web/apps/labelstudio/src/providers/CurrentUser.d.ts
+++ b/web/apps/labelstudio/src/providers/CurrentUser.d.ts
@@ -2,4 +2,6 @@ import type { APIFullUser } from "../../types/User";
declare const useCurrentUser: () => {
user: APIFullUser;
+ fetch: () => Promise
;
+ isInProgress: boolean;
};
diff --git a/web/apps/labelstudio/src/providers/CurrentUser.jsx b/web/apps/labelstudio/src/providers/CurrentUser.jsx
index 050335bb29ed..a805fd891ecf 100644
--- a/web/apps/labelstudio/src/providers/CurrentUser.jsx
+++ b/web/apps/labelstudio/src/providers/CurrentUser.jsx
@@ -6,18 +6,21 @@ const CurrentUserContext = createContext();
export const CurrentUserProvider = ({ children }) => {
const api = useAPI();
const [user, setUser] = useState();
+ const [isInProgress, setIsInProgress] = useState(false);
const fetch = useCallback(() => {
- api.callApi("me").then((user) => {
- setUser(user);
- });
+ setIsInProgress(true);
+ api
+ .callApi("me")
+ .then((user) => setUser(user))
+ .finally(() => setIsInProgress(false));
}, []);
useEffect(() => {
fetch();
}, [fetch]);
- return {children};
+ return {children};
};
export const useCurrentUser = () => useContext(CurrentUserContext) ?? {};
diff --git a/web/libs/core/src/index.ts b/web/libs/core/src/index.ts
index 2accb43a3969..628194624761 100644
--- a/web/libs/core/src/index.ts
+++ b/web/libs/core/src/index.ts
@@ -1,2 +1,3 @@
export * from "./lib/utils/analytics";
+export * from "./pages";
export * from "./lib/Tour";
diff --git a/web/libs/core/src/pages/AccountSettings/AccountSettings.module.scss b/web/libs/core/src/pages/AccountSettings/AccountSettings.module.scss
new file mode 100644
index 000000000000..d0ef9ca60fc6
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/AccountSettings.module.scss
@@ -0,0 +1,42 @@
+.accountSettings {
+ display: flex;
+ flex-direction: column;
+
+ &__content {
+ max-width: 660px;
+
+ h1 {
+ font-size: var(--font-size-header, 28px);
+ margin: 0;
+ }
+ }
+}
+
+.sectionContent {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ gap: var(--spacing-large);
+}
+
+.flexRow {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-large);
+
+ &.flexEnd {
+ justify-content: flex-end;
+ }
+}
+
+.flex1 {
+ flex: 1;
+}
+
+.userPic {
+ flex: none;
+}
+
+.saveButton {
+ width: 125px;
+}
\ No newline at end of file
diff --git a/web/libs/core/src/pages/AccountSettings/AccountSettings.tsx b/web/libs/core/src/pages/AccountSettings/AccountSettings.tsx
new file mode 100644
index 000000000000..bd2454bbe280
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/AccountSettings.tsx
@@ -0,0 +1,51 @@
+import { useMemo } from "react";
+import { Redirect } from "react-router-dom";
+import { SidebarMenu } from "/apps/labelstudio/src/components/SidebarMenu/SidebarMenu";
+import styles from "./AccountSettings.module.scss";
+import { accountSettingsSections } from "./sections";
+import { Card } from "@humansignal/ui";
+
+export const AccountSettingsPage = () => {
+ const menuItems = useMemo(
+ () =>
+ accountSettingsSections.map(({ title, id }) => ({
+ title,
+ path: () => {
+ if (!window?.location) return;
+ window.location.hash = `#${id}`;
+ },
+ })),
+ [accountSettingsSections],
+ );
+
+ return (
+
+
+
+ {accountSettingsSections?.map(({ component: Section, id }: any) => (
+
+
+
+ ))}
+
+
+
+ );
+};
+
+AccountSettingsPage.title = "My Account";
+AccountSettingsPage.path = "/user/account";
+AccountSettingsPage.exact = true;
+AccountSettingsPage.routes = () => [
+ {
+ title: () => "My Account",
+ exact: true,
+ component: () => {
+ return ;
+ },
+ // pages: {
+ // DataManagerPage,
+ // SettingsPage,
+ // },
+ },
+];
diff --git a/web/libs/core/src/pages/AccountSettings/sections/EmailPreferences.tsx b/web/libs/core/src/pages/AccountSettings/sections/EmailPreferences.tsx
new file mode 100644
index 000000000000..92830886a1da
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/sections/EmailPreferences.tsx
@@ -0,0 +1,46 @@
+import { useCallback, useState } from "react";
+import { Checkbox } from "@humansignal/ui";
+import { useConfig } from "/apps/labelstudio/src/providers/ConfigProvider";
+import { useAPI } from "apps/labelstudio/src/providers/ApiProvider";
+import { useCurrentUser } from "/apps/labelstudio/src/providers/CurrentUser";
+import { Spinner } from "/apps/labelstudio/src/components/Spinner/Spinner";
+
+export const EmailPreferences = () => {
+ const config = useConfig();
+ const { user } = useCurrentUser();
+ const api = useAPI();
+ const [isLoading, setIsLoading] = useState(false);
+ const [isAllowNewsLetter, setIsAllowNewsLetter] = useState(config.user.allow_newsletters);
+
+ const toggleHandler = useCallback(
+ async (e: any) => {
+ setIsAllowNewsLetter(e.target.checked);
+ setIsLoading(true);
+ await api.callApi("updateUser", {
+ params: {
+ pk: user?.id,
+ },
+ body: {
+ allow_newsletters: e.target.checked ? 1 : 0,
+ },
+ });
+ setIsLoading(false);
+ },
+ [user?.id],
+ );
+
+ return (
+
+
+
Email Preferences
+
+ {isLoading ? (
+
+ ) : (
+
+ Subscribe to HumanSignal news and tips from Heidi
+
+ )}
+
+ );
+};
diff --git a/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.module.scss b/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.module.scss
new file mode 100644
index 000000000000..2d4a3ec934e2
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.module.scss
@@ -0,0 +1,11 @@
+.membershipInfo {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.divider {
+ display: block;
+ height: 1px;
+ background-color: var(--sand_600, #6B6860);
+}
\ No newline at end of file
diff --git a/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.tsx b/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.tsx
new file mode 100644
index 000000000000..9a9e70b9459c
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.tsx
@@ -0,0 +1,94 @@
+import { useCurrentUser } from "/apps/labelstudio/src/providers/CurrentUser";
+import { useAPI } from "/apps/labelstudio/src/providers/ApiProvider";
+import { useEffect, useState } from "react";
+import { ToastType, useToast } from "@humansignal/ui";
+import { format } from "date-fns";
+import styles from "./MembershipInfo.module.scss";
+export const MembershipInfo = () => {
+ const api = useAPI();
+ const toast = useToast();
+ const { user } = useCurrentUser();
+ const [registrationDate, setRegistrationDate] = useState(null);
+ const [annotationsCount, setAnnotationsCount] = useState(null);
+ const [projectsContributedTo, setProjectsContributedTo] = useState(null);
+
+ useEffect(() => {
+ if (!user) return;
+ api
+ .callApi("userMemberships", {
+ params: {
+ pk: user.active_organization,
+ userPk: user.id,
+ },
+ })
+ .then((response: any) => {
+ if (response?.$meta?.ok) {
+ setRegistrationDate(format(new Date(response?.created_at), "dd MMM yyyy, KK:mm a"));
+ setAnnotationsCount(response?.annotations_count);
+ setProjectsContributedTo(response?.contributed_projects_count);
+ } else {
+ toast.show({
+ message: "Failed to fetch membership info",
+ type: ToastType.error,
+ });
+ }
+ });
+ }, [user?.id]);
+
+ return (
+
+
+ Membership Info
+
+
+
+
+
+
Registration date
+
{registrationDate}
+
+
+
+
Annotations submitted
+
{annotationsCount}
+
+
+
+
Projects contributed to
+
{projectsContributedTo}
+
+
+
+
+
+
+
+
+
+
Organization ID
+
{user?.active_organization}
+
+
+
+
Owner
+
{user?.email}
+
+
+
+
Created
+
{registrationDate}
+
+
+ );
+};
diff --git a/web/libs/core/src/pages/AccountSettings/sections/PersonalAccessToken.tsx b/web/libs/core/src/pages/AccountSettings/sections/PersonalAccessToken.tsx
new file mode 100644
index 000000000000..a5a2f20d94df
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/sections/PersonalAccessToken.tsx
@@ -0,0 +1,46 @@
+import { Input, TextArea } from "/apps/labelstudio/src/components/Form";
+import { Button } from "/apps/labelstudio/src/components/Button/Button";
+import { IconLaunch, IconFileCopy } from "@humansignal/ui";
+
+export const PersonalAccessToken = () => {
+ return (
+
+
+
Personal Access Token
+
+ Authenticate with our API using your personal access token.
+ {!APP_SETTINGS?.whitelabel_is_active && (
+ <>
+ See{" "}
+
+ Docs{" "}
+
+
+
+
+ >
+ )}
+
+
+
+ }>
+ Copy
+
+
+
+
+
+ }>
+ Copy
+
+
+
+ );
+};
diff --git a/web/libs/core/src/pages/AccountSettings/sections/PersonalInfo.tsx b/web/libs/core/src/pages/AccountSettings/sections/PersonalInfo.tsx
new file mode 100644
index 000000000000..87f02a3d74aa
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/sections/PersonalInfo.tsx
@@ -0,0 +1,135 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import clsx from "clsx";
+import { InputFile, useToast } from "@humansignal/ui";
+import { Input } from "/apps/labelstudio/src/components/Form/Elements";
+import { Userpic } from "/apps/labelstudio/src/components/Userpic/Userpic";
+import { useCurrentUser } from "/apps/labelstudio/src/providers/CurrentUser";
+import { Button } from "/apps/labelstudio/src/components/Button/Button";
+import { useAPI } from "apps/labelstudio/src/providers/ApiProvider";
+import styles from "../AccountSettings.module.scss";
+
+export const PersonalInfo = () => {
+ const api = useAPI();
+ const toast = useToast();
+ const { user, fetch, isInProgress: userInProgress } = useCurrentUser();
+ const [fname, setFName] = useState("");
+ const [lname, setLName] = useState("");
+ const [email, setEmail] = useState("");
+ const [phone, setPhone] = useState("");
+ const [isInProgress, setIsInProgress] = useState(false);
+ const userInfoForm = useRef();
+ const userAvatarForm = useRef();
+ const avatarRef = useRef();
+ const fileChangeHandler = (e) => userAvatarForm.current.requestSubmit();
+ const avatarFormSubmitHandler = useCallback(
+ async (e, isDelete = false) => {
+ e.preventDefault();
+ const response = await api.callApi(isDelete ? "deleteUserAvatar" : "updateUserAvatar", {
+ params: {
+ pk: user?.id,
+ },
+ body: {
+ avatar: avatarRef.current.files[0],
+ },
+ headers: {
+ "Content-Type": "multipart/form-data",
+ },
+ errorFilter: () => true,
+ });
+ if (!isDelete && response?.status) {
+ toast.show({ message: response?.response?.detail ?? "Error updating avatar", type: "error" });
+ } else {
+ fetch();
+ }
+ userAvatarForm.current.reset();
+ },
+ [user?.id, fetch],
+ );
+ const userFormSubmitHandler = useCallback(
+ async (e) => {
+ e.preventDefault();
+ const response = await api.callApi("updateUser", {
+ params: {
+ pk: user?.id,
+ },
+ body: {
+ first_name: fname,
+ last_name: lname,
+ phone,
+ },
+ errorFilter: () => true,
+ });
+ if (response?.status) {
+ toast.show({ message: response?.response?.detail ?? "Error updating user", type: "error" });
+ } else {
+ fetch();
+ }
+ },
+ [fname, lname, phone, user?.id],
+ );
+
+ useEffect(() => {
+ if (userInProgress) return;
+ setFName(user?.first_name);
+ setLName(user?.last_name);
+ setEmail(user?.email);
+ setPhone(user?.phone);
+ setIsInProgress(userInProgress);
+ }, [user, userInProgress]);
+
+ useEffect(() => setIsInProgress(userInProgress), [userInProgress]);
+
+ return (
+
+
+
+
Personal Info
+
+
+
+ {user?.avatar && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/web/libs/core/src/pages/AccountSettings/sections/index.tsx b/web/libs/core/src/pages/AccountSettings/sections/index.tsx
new file mode 100644
index 000000000000..baf7fbe67122
--- /dev/null
+++ b/web/libs/core/src/pages/AccountSettings/sections/index.tsx
@@ -0,0 +1,32 @@
+import { PersonalInfo } from "./PersonalInfo";
+import { EmailPreferences } from "./EmailPreferences";
+import type React from "react";
+import { PersonalAccessToken } from "./PersonalAccessToken";
+import { MembershipInfo } from "./MembershipInfo";
+type SectionType = {
+ title: string;
+ id: string;
+ component: React.FC;
+};
+export const accountSettingsSections: SectionType[] = [
+ {
+ title: "Personal Info",
+ id: "personal-info",
+ component: PersonalInfo,
+ },
+ {
+ title: "Email Preferences",
+ id: "email-preferences",
+ component: EmailPreferences,
+ },
+ {
+ title: "Personal Access Token",
+ id: "personal-access-token",
+ component: PersonalAccessToken,
+ },
+ {
+ title: "Membership Info",
+ id: "membership-info",
+ component: MembershipInfo,
+ },
+];
diff --git a/web/libs/core/src/pages/index.ts b/web/libs/core/src/pages/index.ts
new file mode 100644
index 000000000000..864d65fa97c4
--- /dev/null
+++ b/web/libs/core/src/pages/index.ts
@@ -0,0 +1 @@
+export * from "./AccountSettings/AccountSettings";
diff --git a/web/libs/ui/src/assets/icons/file-copy.svg b/web/libs/ui/src/assets/icons/file-copy.svg
new file mode 100644
index 000000000000..c0d054afee1e
--- /dev/null
+++ b/web/libs/ui/src/assets/icons/file-copy.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/libs/ui/src/assets/icons/index.ts b/web/libs/ui/src/assets/icons/index.ts
index 7ad660b91020..2eb95e2dae7f 100644
--- a/web/libs/ui/src/assets/icons/index.ts
+++ b/web/libs/ui/src/assets/icons/index.ts
@@ -1 +1,4 @@
export { ReactComponent as IconCross } from "./cross.svg";
+export { ReactComponent as IconUpload } from "./upload.svg";
+export { ReactComponent as IconLaunch } from "./launch.svg";
+export { ReactComponent as IconFileCopy } from "./file-copy.svg";
diff --git a/web/libs/ui/src/assets/icons/launch.svg b/web/libs/ui/src/assets/icons/launch.svg
new file mode 100644
index 000000000000..c48173687ac9
--- /dev/null
+++ b/web/libs/ui/src/assets/icons/launch.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/libs/ui/src/assets/icons/upload.svg b/web/libs/ui/src/assets/icons/upload.svg
new file mode 100644
index 000000000000..22fc82a9c819
--- /dev/null
+++ b/web/libs/ui/src/assets/icons/upload.svg
@@ -0,0 +1,3 @@
+
diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts
index 72ab72d32482..5c3c93ceb772 100644
--- a/web/libs/ui/src/index.ts
+++ b/web/libs/ui/src/index.ts
@@ -1,5 +1,7 @@
export * from "./lib/checkbox/checkbox";
export * from "./lib/Toast/Toast";
+export * from "./lib/InputFile/InputFile";
+export * from "./lib/Card/Card";
export * from "./lib/label/label";
export * from "./lib/toggle/toggle";
diff --git a/web/libs/ui/src/lib/Card/Card.module.scss b/web/libs/ui/src/lib/Card/Card.module.scss
new file mode 100644
index 000000000000..9a85a3f2170e
--- /dev/null
+++ b/web/libs/ui/src/lib/Card/Card.module.scss
@@ -0,0 +1,30 @@
+.card {
+ border-radius: 5px;
+ background-color: var(--sand_0);
+ border: 1px solid var(--sand_300);
+
+ &__header {
+ display: flex;
+ height: 48px;
+ padding: 0 15px;
+ align-items: center;
+ font-weight: 500;
+ font-size: 16px;
+ line-height: 18px;
+ justify-content: space-between;
+ box-shadow: 0 1px 0 0 rgb(0 0 0 / 10%);
+
+ &-content {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ &__content {
+ padding: 15px;
+ }
+
+ &:not(:first-child) {
+ margin-top: 24px;
+ }
+}
\ No newline at end of file
diff --git a/web/libs/ui/src/lib/Card/Card.tsx b/web/libs/ui/src/lib/Card/Card.tsx
new file mode 100644
index 000000000000..bd0270d02082
--- /dev/null
+++ b/web/libs/ui/src/lib/Card/Card.tsx
@@ -0,0 +1,23 @@
+import styles from "./Card.module.scss";
+
+type CardProps = {
+ header?: React.ReactNode;
+ extra?: React.ReactNode;
+ children: React.ReactNode;
+ style?: React.CSSProperties;
+};
+
+export const Card = ({ header, extra, children, style }: CardProps) => {
+ return (
+
+ {(header || extra) && (
+
+
{header}
+
+ {extra &&
{extra}
}
+
+ )}
+
{children}
+
+ );
+};
diff --git a/web/libs/ui/src/lib/InputFile/InputFile.module.scss b/web/libs/ui/src/lib/InputFile/InputFile.module.scss
new file mode 100644
index 000000000000..cd0bd2341dac
--- /dev/null
+++ b/web/libs/ui/src/lib/InputFile/InputFile.module.scss
@@ -0,0 +1,37 @@
+.input {
+ border: 0 none;
+ padding: 0;
+ display: block;
+ width: 100%;
+ cursor: pointer;
+ outline: none;
+}
+
+.labelContent {
+ position: absolute;
+ left: 0;
+ top: 0;
+ border: 1px solid var(--primary_link);
+ color: var(--primary_link);
+ border-radius: var(--radius-xs);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-small);
+ background-color: var(--white);
+}
+
+.inputWrapper {
+ position: relative;
+ display: flex;
+ height: 42px;
+ width: 100%;
+ margin: 0;
+ cursor: pointer;
+ align-items: center;
+ outline: none;
+
+ &:focus-within .labelContent {
+ outline: 2px solid var(--primary_link);
+ }
+}
\ No newline at end of file
diff --git a/web/libs/ui/src/lib/InputFile/InputFile.tsx b/web/libs/ui/src/lib/InputFile/InputFile.tsx
new file mode 100644
index 000000000000..4bb65f5f409f
--- /dev/null
+++ b/web/libs/ui/src/lib/InputFile/InputFile.tsx
@@ -0,0 +1,43 @@
+import { IconUpload } from "../../assets/icons";
+import clsx from "clsx";
+type InputFileProps = {
+ name?: string;
+ className?: string;
+ text?: React.ReactNode | string;
+ onChange?: (e: React.ChangeEvent) => void;
+ props?: Record;
+};
+import styles from "./InputFile.module.scss";
+import type React from "react";
+import { forwardRef, useCallback, useRef } from "react";
+export const InputFile = forwardRef(({ name, className, text, onChange, ...props }: InputFileProps, ref: any) => {
+ if (!ref) {
+ ref = useRef();
+ }
+ const interactiveKeys = ["Space", " "];
+ const wrapperKeyDownHandler = useCallback(
+ (e: any) => {
+ if (interactiveKeys.includes(e.key)) {
+ e.preventDefault();
+ ref.current.click();
+ }
+ },
+ [ref],
+ );
+ return (
+
+ );
+});