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 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 }) => { , , , + , isFF(FF_PRODUCT_TOUR) && , ].filter(Boolean)} > diff --git a/web/apps/labelstudio/src/components/Form/Form.scss b/web/apps/labelstudio/src/components/Form/Form.scss index cfeb0ab78c15..7c10d1a4923d 100644 --- a/web/apps/labelstudio/src/components/Form/Form.scss +++ b/web/apps/labelstudio/src/components/Form/Form.scss @@ -59,11 +59,26 @@ .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/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 ( + + ); +}; 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 + +

+
+
User ID
+
{user?.id}
+
+ +
+
Registration date
+
{registrationDate}
+
+ +
+
Annotations submitted
+
{annotationsCount}
+
+ +
+
Projects contributed to
+
{projectsContributedTo}
+
+ +
+ + + +
+
My role
+
Owner
+
+ +
+
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{" "} + + + + + + )} +

+
+ + + +
+
+