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 %} - - -
-
-
- - -
Account info
-
    -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
  • - - - -
  • -
-
- -
-
- {% if user.avatar %} - User photo - {% endif %} - - {% if user.get_initials %} - {{user.get_initials}} - {% else %} - {{user.username}} - {% endif %} -
- - - - -
- - -
-
-

Registered {{ user.date_joined|date:"M j, Y" }}, user ID {{ user.id }}

- -
-
- - -
-
Access Token
-
- - -

- - -

-
- -
- - -

- - - {% block api_docs %} - - Documentation - - {% endblock %} -

-
-
- - - -
-
- {{ user.active_organization.title }} -
- Your active organization -
- - - {% with user.get_pretty_role as role %} - {% if role %} - - {% endif %} - {% endwith %} - - - - - - -
Your role{{ user.get_pretty_role }}
Annotations completed by you{{ user.active_organization_annotations.count }}
Projects contributed by you{{ user.active_organization_contributed_project_number }}
Organization ID{{ user.active_organization.id }}
Organization owner{{ user.active_organization.created_by }}
Organization created at{{ user.active_organization.created_at }}
- -
- - - {% block notifications %} -
-
- Notifications -
- Email and other notifications -
- - - -
- - - - - - - -
- -
- {% endblock %} - - -
- - -
- -{% endblock %} diff --git a/label_studio/users/views.py b/label_studio/users/views.py index 75fc8017bedf..fcbedb84df2b 100644 --- a/label_studio/users/views.py +++ b/label_studio/users/views.py @@ -148,6 +148,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 c5581b515447..89823367aca6 100644 --- a/web/apps/labelstudio/src/app/App.jsx +++ b/web/apps/labelstudio/src/app/App.jsx @@ -18,6 +18,7 @@ import ErrorBoundary from "./ErrorBoundary"; import { RootPage } from "./RootPage"; import { FF_OPTIC_2, FF_UNSAVED_CHANGES, isFF } from "../utils/feature-flags"; 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"; @@ -61,6 +62,7 @@ const App = ({ content }) => { , , , + , ]} > diff --git a/web/apps/labelstudio/src/assets/icons/index.js b/web/apps/labelstudio/src/assets/icons/index.js index b8755e330d33..762572f0f0e2 100644 --- a/web/apps/labelstudio/src/assets/icons/index.js +++ b/web/apps/labelstudio/src/assets/icons/index.js @@ -29,5 +29,6 @@ export { ReactComponent as IconSparkGrey } from "./spark-grey.svg"; export { ReactComponent as IconPredictions } from "./predictions.svg"; export { ReactComponent as IconEmptyPredictions } from "./empty-predictions.svg"; export { ReactComponent as IconSpark } from "./spark.svg"; +export { ReactComponent as IconLaunch } from "./launch.svg"; export { ReactComponent as IconModel } from "./model.svg"; export { ReactComponent as IconModels } from "./models.svg"; diff --git a/web/apps/labelstudio/src/assets/icons/launch.svg b/web/apps/labelstudio/src/assets/icons/launch.svg new file mode 100644 index 000000000000..c48173687ac9 --- /dev/null +++ b/web/apps/labelstudio/src/assets/icons/launch.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/apps/labelstudio/src/components/Form/Form.scss b/web/apps/labelstudio/src/components/Form/Form.scss index cfeb0ab78c15..0abeea3ad134 100644 --- a/web/apps/labelstudio/src/components/Form/Form.scss +++ b/web/apps/labelstudio/src/components/Form/Form.scss @@ -59,11 +59,24 @@ .counter, .select-ls__list { &:not(&_ghost):focus, + &:not(:read-only):focus, &_focused { outline: none; box-shadow: 0 0 0 6px var(--grape_100), inset 0 -1px 0 rgb(0 0 0 / 10%), inset 0 0 0 1px rgb(0 0 0 / 15%), inset 0 0 0 1px var(--grape_100); border-color: var(--grape_100); } + &:focus-visible { + outline: none; + } + + &:read-only:focus { + box-shadow: none; + border-color: var(--border-color); + } + &:read-only { + background-color: var(--sand_100); + color: var(--sand_500); + } } .form-indicator { diff --git a/web/apps/labelstudio/src/components/Menubar/Menubar.jsx b/web/apps/labelstudio/src/components/Menubar/Menubar.jsx index 7f13680a47fe..ffe00abf4796 100644 --- a/web/apps/labelstudio/src/components/Menubar/Menubar.jsx +++ b/web/apps/labelstudio/src/components/Menubar/Menubar.jsx @@ -14,6 +14,7 @@ import { } from "../../assets/icons"; import { useConfig } from "../../providers/ConfigProvider"; import { useContextComponent, useFixedLocation } from "../../providers/RoutesProvider"; +import { useCurrentUser } from "../../providers/CurrentUser"; import { cn } from "../../utils/bem"; import { absoluteURL, isDefined } from "../../utils/helpers"; import { Breadcrumbs } from "../Breadcrumbs/Breadcrumbs"; @@ -27,6 +28,7 @@ import "./MenuContent.scss"; import "./MenuSidebar.scss"; import { ModelsPage } from "../../pages/Organization/Models/ModelsPage"; import { FF_DIA_835, isFF } from "../../utils/feature-flags"; +import { AccountSettingsPage } from "@humansignal/core"; export const MenubarContext = createContext(); @@ -51,6 +53,7 @@ const RightContextMenu = ({ className, ...props }) => { export const Menubar = ({ enabled, defaultOpened, defaultPinned, children, onSidebarToggle, onSidebarPin }) => { const menuDropdownRef = useRef(); const useMenuRef = useRef(); + const { user, fetch, isInProgress } = useCurrentUser(); const location = useFixedLocation(); const config = useConfig(); @@ -66,7 +69,7 @@ export const Menubar = ({ enabled, defaultOpened, defaultPinned, children, onSid const sidebarClass = cn("sidebar"); const contentClass = cn("content-wrapper"); const contextItem = menubarClass.elem("context-item"); - const showNewsletterDot = !isDefined(config.user.allow_newsletters); + const showNewsletterDot = !isDefined(user?.allow_newsletters); const sidebarPin = useCallback( (e) => { @@ -148,13 +151,13 @@ export const Menubar = ({ enabled, defaultOpened, defaultPinned, children, onSid align="right" content={ - } label="Account & Settings" href="/user/account" data-external /> + } label="Account & Settings" href={AccountSettingsPage.path} /> {/* */} } label="Log Out" href={absoluteURL("/logout")} data-external /> {showNewsletterDot && ( <> - + Please check new notification settings in the Account & Settings page @@ -163,8 +166,8 @@ export const Menubar = ({ enabled, defaultOpened, defaultPinned, children, onSid } > -
- +
+ {showNewsletterDot &&
}
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 8541f13ae1aa..9d593f7c8087 100644 --- a/web/apps/labelstudio/src/config/ApiConfig.js +++ b/web/apps/labelstudio/src/config/ApiConfig.js @@ -3,6 +3,9 @@ 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 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..9d16943990ec 100644 --- a/web/apps/labelstudio/src/providers/CurrentUser.jsx +++ b/web/apps/labelstudio/src/providers/CurrentUser.jsx @@ -6,18 +6,20 @@ 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/package.json b/web/libs/core/package.json new file mode 100644 index 000000000000..b0cb866bbd68 --- /dev/null +++ b/web/libs/core/package.json @@ -0,0 +1,11 @@ +{ + "name": "@humansignal/core", + "version": "0.0.0", + "license": "MIT", + "private": true, + "dependencies": { + "react": "17.0.2", + "react-dom": "17.0.2" + }, + "main": "src/index.ts" +} diff --git a/web/libs/core/src/index.ts b/web/libs/core/src/index.ts index 90305f0e152a..acc86ac2616c 100644 --- a/web/libs/core/src/index.ts +++ b/web/libs/core/src/index.ts @@ -1 +1,2 @@ export * from "./lib/utils/analytics"; +export * from "./pages"; \ No newline at end of file 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..9bbaae326196 --- /dev/null +++ b/web/libs/core/src/pages/AccountSettings/AccountSettings.module.scss @@ -0,0 +1,41 @@ +.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..2d9f5f2d28e2 --- /dev/null +++ b/web/libs/core/src/pages/AccountSettings/AccountSettings.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import { Redirect } from "react-router-dom"; +// import { useAPI } from "../../../../../apps/labelstudio/src/providers/ApiProvider"; +// 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 api = useAPI(); + const menuItems = useMemo( + () => accountSettingsSections.map(({ title, id }) => ({ title, path: `#${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..6db00c3b730f --- /dev/null +++ b/web/libs/core/src/pages/AccountSettings/sections/EmailPreferences.tsx @@ -0,0 +1,47 @@ +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"; + +export const EmailPreferences = () => { + return <> + // 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) => { + // 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 ( + // + // ); +}; 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..415926ba98d5 --- /dev/null +++ b/web/libs/core/src/pages/AccountSettings/sections/MembershipInfo.tsx @@ -0,0 +1,62 @@ +// import { Divider } from "../../../../../../apps/labelstudio/src/components/Divider/Divider"; +// import { useCurrentUser } from "../../../../../../apps/labelstudio/src/providers/CurrentUser"; +// import { useAPI } from "../../../../../../apps/labelstudio/src/providers/ApiProvider"; +// import { OrganizationPage } from "../../../../../../apps/labelstudio/src/pages/Organization"; +export const MembershipInfo = () => { + // const api = useAPI(); + // const { user } = useCurrentUser(); + const user = {}; + return ( +
+ +

Membership Info

+
+
User ID
+
{user?.id}
+
+ +
+
Registration date
+
+
+ +
+
Annotations submitted
+
+
+ +
+
Projects contributed to
+
+
+ + {/* */} +
+ +
+
My role
+
Owner
+
+ +
+
Organization ID
+
{user?.active_organization}
+
+ +
+
Owner
+
{user?.email}
+
+ +
+
Created
+
+
+
+ ); +}; 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..0f8825fbeade --- /dev/null +++ b/web/libs/core/src/pages/AccountSettings/sections/PersonalAccessToken.tsx @@ -0,0 +1,32 @@ +// import { Input, TextArea } from "../../../../../../apps/labelstudio/src/components/Form"; +// import { Button } from "../../../../../../apps/labelstudio/src/components/Button/Button"; +// import { IconLaunch } from "apps/labelstudio/src/assets/icons"; + +export const PersonalAccessToken = () => { + return ( +
+ +

Personal Access Token

+

+ Authenticate with our API using your personal access token. + {!APP_SETTINGS?.whitelabel_is_active && ( + <> + See{" "} + + {/* Docs */} + + + )} +

+
+ {/* */} + +
+
+ {/*