diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 6c59b47931..a05fcf6078 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -648,6 +648,21 @@ const ProjectController = { } ) }, + personalAccessTokenAssignment(cb) { + SplitTestHandler.getAssignment( + req, + res, + 'personal-access-token', + (error, assignment) => { + // do not fail editor load if assignment fails + if (error) { + cb(null, { variant: 'default' }) + } else { + cb(null, assignment) + } + } + ) + }, historyViewAssignment(cb) { SplitTestHandler.getAssignment( req, @@ -716,6 +731,7 @@ const ProjectController = { historyViewAssignment, reviewPanelAssignment, idePageAssignment, + personalAccessTokenAssignment, projectTags, } ) => { @@ -821,6 +837,10 @@ const ProjectController = { !Features.hasFeature('saas') || req.query?.personal_access_token === 'true' + const optionalPersonalAccessToken = + !showPersonalAccessToken && + personalAccessTokenAssignment.variant === 'enabled' // `?personal-access-token=enabled` + const idePageReact = idePageAssignment.variant === 'react' const template = @@ -903,6 +923,7 @@ const ProjectController = { isReviewPanelReact: reviewPanelAssignment.variant === 'react', idePageReact, showPersonalAccessToken, + optionalPersonalAccessToken, hasTrackChangesFeature: Features.hasFeature('track-changes'), projectTags, }) diff --git a/services/web/app/src/Features/User/UserPagesController.js b/services/web/app/src/Features/User/UserPagesController.js index 28dbbdac87..8e96000d5e 100644 --- a/services/web/app/src/Features/User/UserPagesController.js +++ b/services/web/app/src/Features/User/UserPagesController.js @@ -10,6 +10,7 @@ const SubscriptionLocator = require('../Subscription/SubscriptionLocator') const _ = require('lodash') const { expressify } = require('../../util/promises') const Features = require('../../infrastructure/Features') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') async function settingsPage(req, res) { const userId = SessionManager.getLoggedInUserId(req.session) @@ -69,8 +70,27 @@ async function settingsPage(req, res) { const showPersonalAccessToken = !Features.hasFeature('saas') || req.query?.personal_access_token === 'true' + + // if not already enabled, use a split test to determine whether to offer personal access tokens + let optionalPersonalAccessToken = false + if (!showPersonalAccessToken) { + try { + const { variant } = await SplitTestHandler.promises.getAssignment( + req, + res, + 'personal-access-token' + ) + optionalPersonalAccessToken = variant === 'enabled' // `?personal-access-token=enabled` + } catch (error) { + logger.error( + { err: error }, + 'Failed to get personal-access-token split test assignment' + ) + } + } + let personalAccessTokens - if (showPersonalAccessToken) { + if (showPersonalAccessToken || optionalPersonalAccessToken) { try { // require this here because module may not be included in some versions const PersonalAccessTokenManager = require('../../../../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager') @@ -133,6 +153,7 @@ async function settingsPage(req, res) { thirdPartyIds: UserPagesController._restructureThirdPartyIds(user), projectSyncSuccessMessage, showPersonalAccessToken, + optionalPersonalAccessToken, personalAccessTokens, emailAddressLimit: Settings.emailAddressLimit, isManagedAccount: !!req.managedBy, diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index fcadeb204d..005027bfa3 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/services/web/app/views/project/editor/meta.pug @@ -36,6 +36,7 @@ meta(name="ol-showSupport", data-type="boolean" content=showSupport) meta(name="ol-showTemplatesServerPro", data-type="boolean" content=showTemplatesServerPro) meta(name="ol-showCM6SwitchAwaySurvey", data-type="boolean" content=showCM6SwitchAwaySurvey) meta(name="ol-showPersonalAccessToken", data-type="boolean" content=showPersonalAccessToken) +meta(name="ol-optionalPersonalAccessToken", data-type="boolean" content=optionalPersonalAccessToken) meta(name="ol-isReviewPanelReact", data-type="boolean" content=isReviewPanelReact) meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature) meta(name="ol-mathJax3Path" content=mathJax3Path) diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 65e6a8b5dc..e5d8582095 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -23,6 +23,7 @@ block append meta meta(name="ol-github" data-type="json" content=github) meta(name="ol-projectSyncSuccessMessage", content=projectSyncSuccessMessage) meta(name="ol-showPersonalAccessToken", data-type="boolean" content=showPersonalAccessToken) + meta(name="ol-optionalPersonalAccessToken", data-type="boolean" content=optionalPersonalAccessToken) meta(name="ol-personalAccessTokens", data-type="json" content=personalAccessTokens) meta(name="ol-emailAddressLimit", data-type="json", content=emailAddressLimit) meta(name="ol-currentManagedUserAdminEmail" data-type="string" content=currentManagedUserAdminEmail) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 6fa147eb0a..9f0631c583 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -400,9 +400,11 @@ "git_bridge_modal_click_generate": "", "git_bridge_modal_description": "", "git_bridge_modal_enter_authentication_token": "", + "git_bridge_modal_git_authentication_tokens": "", "git_bridge_modal_see_once": "", "git_bridge_modal_tokens_description": "", "git_bridge_modal_use_previous_token": "", + "git_bridge_modal_you_can_also_git_clone": "", "git_integration": "", "git_integration_info": "", "github_commit_message_placeholder": "", diff --git a/services/web/frontend/js/features/settings/components/linking-section.tsx b/services/web/frontend/js/features/settings/components/linking-section.tsx index 250fb9eef1..0d02a5c250 100644 --- a/services/web/frontend/js/features/settings/components/linking-section.tsx +++ b/services/web/frontend/js/features/settings/components/linking-section.tsx @@ -31,11 +31,11 @@ function LinkingSection() { path: string }[] - const showPersonalAccessToken = getMeta( - 'ol-showPersonalAccessToken' - ) as boolean + const showPersonalAccessTokenComponents: boolean = + getMeta('ol-showPersonalAccessToken') || + getMeta('ol-optionalPersonalAccessToken') - const allIntegrationLinkingWidgets = showPersonalAccessToken + const allIntegrationLinkingWidgets = showPersonalAccessTokenComponents ? integrationLinkingWidgets.concat(oauth2ServerComponents) : integrationLinkingWidgets diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx index 13dda1e38e..c7a9c1e552 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.jsx @@ -6,7 +6,7 @@ import Tooltip from '../../../shared/components/tooltip' import Icon from '../../../shared/components/icon' import { useShareProjectContext } from './share-project-modal' import { setProjectAccessLevel } from '../utils/api' -import CopyLink from '../../../shared/components/copy-link' +import { CopyToClipboard } from '@/shared/components/copy-to-clipboard' import { useProjectContext } from '../../../shared/context/project-context' import * as eventTracking from '../../../infrastructure/event-tracking' import { useUserContext } from '../../../shared/context/user-context' @@ -265,10 +265,10 @@ function AccessToken({ token, path, tooltipId }) { const link = `${origin}${path}${token}` return ( -
-      {link}
-      
-    
+
+ {link} + +
) } diff --git a/services/web/frontend/js/shared/components/copy-link.tsx b/services/web/frontend/js/shared/components/copy-link.tsx deleted file mode 100644 index 96603a0beb..0000000000 --- a/services/web/frontend/js/shared/components/copy-link.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useCallback, useState } from 'react' -import { Button } from 'react-bootstrap' -import { Trans, useTranslation } from 'react-i18next' -import Tooltip from './tooltip' -import Icon from './icon' - -type CopyLinkProps = { - link: string - tooltipId: string -} - -function CopyLink({ link, tooltipId }: CopyLinkProps) { - const { t } = useTranslation() - - const [copied, setCopied] = useState(false) - - const handleClick = useCallback(() => { - navigator.clipboard.writeText(link).then(() => { - setCopied(true) - window.setTimeout(() => { - setCopied(false) - }, 1500) - }) - }, [link]) - - if (!navigator.clipboard?.writeText) { - return null - } - - return ( - } - overlayProps={{ - delayHide: copied ? 1000 : 250, - shouldUpdatePosition: true, - }} - > - - - ) -} - -export default CopyLink diff --git a/services/web/frontend/js/shared/components/copy-to-clipboard.tsx b/services/web/frontend/js/shared/components/copy-to-clipboard.tsx new file mode 100644 index 0000000000..af22f0fa1d --- /dev/null +++ b/services/web/frontend/js/shared/components/copy-to-clipboard.tsx @@ -0,0 +1,84 @@ +import { FC, memo, MouseEventHandler, useCallback, useState } from 'react' +import { Button } from 'react-bootstrap' +import { useTranslation } from 'react-i18next' +import Tooltip from './tooltip' +import Icon from './icon' + +export const CopyToClipboard = memo<{ + content: string + tooltipId: string + kind?: 'text' | 'icon' +}>(({ content, tooltipId, kind = 'icon' }) => { + const { t } = useTranslation() + + const [copied, setCopied] = useState(false) + + const handleClick = useCallback(() => { + navigator.clipboard.writeText(content).then(() => { + setCopied(true) + window.setTimeout(() => { + setCopied(false) + }, 1500) + }) + }, [content]) + + if (!navigator.clipboard?.writeText) { + return null + } + + return ( + + + {kind === 'text' ? ( + + ) : ( + + )} + + + ) +}) +CopyToClipboard.displayName = 'CopyToClipboard' + +const TextButton: FC<{ + handleClick: MouseEventHandler + ) +} + +const IconButton: FC<{ + handleClick: MouseEventHandler + ) +} diff --git a/services/web/frontend/stories/decorators/scope.tsx b/services/web/frontend/stories/decorators/scope.tsx index cd31b821a0..7b19f9d2d1 100644 --- a/services/web/frontend/stories/decorators/scope.tsx +++ b/services/web/frontend/stories/decorators/scope.tsx @@ -9,6 +9,7 @@ import { mockCompileError, } from '../fixtures/compile' import useFetchMock from '../hooks/use-fetch-mock' +import { useMeta } from '../hooks/use-meta' const scopeWatchers: [string, (value: any) => void][] = [] @@ -210,7 +211,8 @@ type ScopeDecoratorOptions = { export const ScopeDecorator = ( Story: any, - opts: ScopeDecoratorOptions = { mockCompileOnLoad: true } + opts: ScopeDecoratorOptions = { mockCompileOnLoad: true }, + meta: Record = {} ) => { // mock compile on load useFetchMock(fetchMock => { @@ -232,6 +234,9 @@ export const ScopeDecorator = ( return initialize() }, []) + // set values on window.metaAttributesCache (created in initialize, above) + useMeta(meta) + return ( diff --git a/services/web/frontend/stylesheets/_style_includes.less b/services/web/frontend/stylesheets/_style_includes.less index 7f085d7f14..2d4dc861df 100644 --- a/services/web/frontend/stylesheets/_style_includes.less +++ b/services/web/frontend/stylesheets/_style_includes.less @@ -117,6 +117,6 @@ // TODO: find a way for modules to add styles dynamically @import 'modules/symbol-palette.less'; @import 'modules/managed-users.less'; - +@import 'modules/git-bridge-modal.less'; @import 'modules/admin-panel.less'; @import 'modules/overleaf-integration.less'; diff --git a/services/web/frontend/stylesheets/main-style.less b/services/web/frontend/stylesheets/main-style.less index f8d284558c..3fda7c3163 100644 --- a/services/web/frontend/stylesheets/main-style.less +++ b/services/web/frontend/stylesheets/main-style.less @@ -148,5 +148,6 @@ // TODO: find a way for modules to add styles dynamically @import 'modules/symbol-palette.less'; @import 'modules/admin-panel.less'; +@import 'modules/git-bridge-modal.less'; @import 'modules/managed-users.less'; @import 'modules/overleaf-integration.less'; diff --git a/services/web/frontend/stylesheets/modules/git-bridge-modal.less b/services/web/frontend/stylesheets/modules/git-bridge-modal.less new file mode 100644 index 0000000000..898dfe03ed --- /dev/null +++ b/services/web/frontend/stylesheets/modules/git-bridge-modal.less @@ -0,0 +1,33 @@ +.git-bridge-copy { + background: @neutral-10; + color: @neutral-90; + padding: @padding-sm; + border-radius: @border-radius-large; + display: flex; + justify-content: center; + gap: @padding-sm; + align-items: center; + margin: @margin-lg 0; + + & code { + word-break: break-word; + } +} + +.git-bridge-optional-tokens { + border: 1px solid @gray-lightest; + border-radius: @border-radius-large; + padding: @padding-md; + margin: @margin-lg 0; +} + +.git-bridge-optional-tokens-header { + font-family: @font-family-sans-serif; + font-size: 120%; + font-weight: bold; + margin-bottom: @margin-sm; +} + +.git-bridge-optional-tokens-actions { + margin-top: @margin-sm; +} diff --git a/services/web/locales/da.json b/services/web/locales/da.json index 2eb6b7e016..c68d4d93da 100644 --- a/services/web/locales/da.json +++ b/services/web/locales/da.json @@ -577,8 +577,8 @@ "git_authentication_token": "Git autentificeringsnøgle", "git_authentication_token_create_modal_info_1": "Dette er din Git autentificeringsnøgle. Du skal indtaste den når du bliver spurgt om et kodeord.", "git_authentication_token_create_modal_info_2": "<0>Du kan kun se denne autentificeringsnøgle én gang så kopier den venligst og opbevar den sikkert. For flere instruktioner omkring brugen af autentificeringsnøgler, besøg vores <1>hjælpeside.", - "git_bridge_modal_click_generate": "Klik “Generér nøgle” for at generere din autentificeringsnøgle. Du kan også gøre det senere i dine kontoindstillinger.", - "git_bridge_modal_description": "Du kan git clone dit projekt med linket herunder.", + "git_bridge_modal_click_generate": "Klik “Generér nøgle” for at generere din autentificeringsnøgle. Du kan også gøre det senere i dine kontoindstillinger.", + "git_bridge_modal_description": "Du kan git clone dit projekt med linket herunder.", "git_bridge_modal_enter_authentication_token": "Når du bliver spurgt om en kode, indtast da din nye autentificeringsnøgle:", "git_bridge_modal_see_once": "Du kan kun se denne autentificeringsnøgle én gang. For at slette den eller generere en ny, gå til dine brugerindstilinger. For detalerede instruktioner og fejlsøgning, læs vores <0>hjælpeside.", "git_bridge_modal_tokens_description": "For at git clone dit projekt har du brug for linket herunder og en git autentificeringsnøgle.", diff --git a/services/web/locales/de.json b/services/web/locales/de.json index fc8b76efc1..730640f0b9 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -582,8 +582,8 @@ "git_authentication_token": "Git Anmeldungs-Token", "git_authentication_token_create_modal_info_1": "Das ist dein Git Anmeldungs-Token. Verwende ihn wenn Du nach einem Passwort gefragt wirst.", "git_authentication_token_create_modal_info_2": "<0>Du bekommst diesen Anmelde-Token nur einmal angezeigt, bitte kopiere ihn und bewahre ihn sicher auf. Für weitere Anweisungen zur Verwendung von Anmelde-Tokens, besuche unsere <1>Hilfe-Seite.", - "git_bridge_modal_click_generate": "Klicke jetzt auf Token generieren um deinen ersten Anmeldungs-Token zu erstellen. Oder erstelle ihn später in deinen Kontoeinstellungen.", - "git_bridge_modal_description": "Du kannst git clone für dein Projekt über den unten angezeigten Link ausführen.", + "git_bridge_modal_click_generate": "Klicke jetzt auf Token generieren um deinen ersten Anmeldungs-Token zu erstellen. Oder erstelle ihn später in deinen Kontoeinstellungen.", + "git_bridge_modal_description": "Du kannst git clone für dein Projekt über den unten angezeigten Link ausführen.", "git_bridge_modal_enter_authentication_token": "Wenn Du nach einem Passwort gefragt wirst, gib deinen neuen Anmeldungs-Token ein:", "git_bridge_modal_see_once": "Du siehst diesen Token nur einmal. Um ihn zu löschen oder einen weiteren zu generieren, besuche die Kontoeinstellungen. Für detaillierte Anweisungen und Problembehebung, besuche unsere <0>Hilfe-Seite.", "git_bridge_modal_tokens_description": "Um einen Git-Clone deines Projekts zu erstellen, benutze folgenden Link und einen Git-Anmeldungs-Token.", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 79f8da9cf5..3910d59f55 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -647,12 +647,14 @@ "git_authentication_token": "Git authentication token", "git_authentication_token_create_modal_info_1": "This is your Git authentication token. You should enter this when prompted for a password.", "git_authentication_token_create_modal_info_2": "<0>You will only see this authentication token once so please copy it and keep it safe. For full instructions on using authentication tokens, visit our <1>help page.", - "git_bridge_modal_click_generate": "Click Generate token to generate your authentication token now. Or do this later in your Account Settings.", - "git_bridge_modal_description": "You can git clone your project using the link displayed below.", + "git_bridge_modal_click_generate": "Click Generate token to generate your authentication token now. Or do this later in your Account Settings.", + "git_bridge_modal_description": "You can git clone your project using the link displayed below.", "git_bridge_modal_enter_authentication_token": "When prompted for a password, enter your new authentication token:", + "git_bridge_modal_git_authentication_tokens": "Git authentication tokens", "git_bridge_modal_see_once": "You’ll only see this token once. To delete it or generate a new one, visit Account Settings. For detailed instructions and troubleshooting, read our <0>help page.", "git_bridge_modal_tokens_description": "To git clone your project you’ll need the link below and a Git authentication token.", "git_bridge_modal_use_previous_token": "If you’re prompted for a password, you can use a previously generated Git authentication token. Or you can generate a new one in Account Settings. For more support, read our <0>help page.", + "git_bridge_modal_you_can_also_git_clone": "You can also git clone your project by using the link below and a Git authentication token.", "git_integration": "Git Integration", "git_integration_info": "With Git integration, you can clone your Overleaf projects with Git. For full instructions on how to do this, read <0>our help page.", "git_integration_lowercase": "Git integration", diff --git a/services/web/locales/fr.json b/services/web/locales/fr.json index e8595d8c74..66c4199e31 100644 --- a/services/web/locales/fr.json +++ b/services/web/locales/fr.json @@ -364,7 +364,7 @@ "get_same_latex_setup": "Avec __appName__, votre configuration LaTeX vous suit partout. En travaillant avec vos collègues ou étudiant·e·s sur __appName__, vous êtes sûr·e de ne pas rencontrer d’incohérences de version ou de conflits de paquets.", "get_started_now": "Commencer maintenant", "git": "Git", - "git_bridge_modal_description": "Vous pouvez effectuer un git clone de votre projet en utilisant le lien ci-dessous.", + "git_bridge_modal_description": "Vous pouvez effectuer un git clone de votre projet en utilisant le lien ci-dessous.", "github_commit_message_placeholder": "Message de commit pour les changements effectués dans __appName__…", "github_credentials_expired": "Vos identifiants GitHub ont expiré", "github_for_link_shared_projects": "Vous avez accédé à ce projet par un partage de lien : celui-ci ne sera pas synchronisé à votre GitHub tant que vous n’aurez pas été invité par courriel par le propriétaire du projet.", diff --git a/services/web/locales/pt.json b/services/web/locales/pt.json index 1ee9191c17..e612ae8c16 100644 --- a/services/web/locales/pt.json +++ b/services/web/locales/pt.json @@ -261,7 +261,7 @@ "get_same_latex_setup": "Com __appName__ você tem o mesmo aplicativo onde quer que vá. Trabalhando com seus colegas e estudantes no __appName__, você sabe que não terá problema de inconsistência ou conflito de pacotes.", "get_started_now": "Comece agora", "git": "Git", - "git_bridge_modal_description": "Você pode usar git clone no seu projeto usando o link abaixo.", + "git_bridge_modal_description": "Você pode usar git clone no seu projeto usando o link abaixo.", "github_commit_message_placeholder": "Mensagem de commit para as alterações feitas no __appName__...", "github_credentials_expired": "Suas credenciais de autorização do GitHub expiraram", "github_for_link_shared_projects": "Este projeto foi acessado por meio do compartilhamento de links e não será sincronizado com o seu Github, a menos que você seja convidado por e-mail pelo proprietário do projeto.", diff --git a/services/web/locales/zh-CN.json b/services/web/locales/zh-CN.json index 8c15d6123a..c291c184a4 100644 --- a/services/web/locales/zh-CN.json +++ b/services/web/locales/zh-CN.json @@ -362,7 +362,7 @@ "get_same_latex_setup": "您到任何地方都可以用 __appName__ 实现LaTeX的功能。由于您和您的同事和学生可以在 __appName__ 上共同工作,不会出现版本不一致和包冲突的情况。", "get_started_now": "立即开始", "git": "Git", - "git_bridge_modal_description": "您可以使用下面显示的链接来gitclone您的项目。", + "git_bridge_modal_description": "您可以使用下面显示的链接来git clone您的项目。", "github_commit_message_placeholder": "为 __appName__ 中的更改提交信息", "github_credentials_expired": "您的 Github 授权凭证已过期", "github_for_link_shared_projects": "此项目是通过链接共享访问的,除非项目所有者通过电子邮件邀请您,否则不会与您的Github同步。", diff --git a/services/web/test/unit/src/User/UserPagesControllerTests.js b/services/web/test/unit/src/User/UserPagesControllerTests.js index 36f7cbc8ee..e4c53a86bc 100644 --- a/services/web/test/unit/src/User/UserPagesControllerTests.js +++ b/services/web/test/unit/src/User/UserPagesControllerTests.js @@ -82,6 +82,11 @@ describe('UserPagesController', function () { getAdminEmail: sinon.stub().returns(this.adminEmail), }, } + this.SplitTestHandler = { + promises: { + getAssignment: sinon.stub().returns('default'), + }, + } this.UserPagesController = SandboxedModule.require(modulePath, { requires: { '@overleaf/settings': this.settings, @@ -96,6 +101,7 @@ describe('UserPagesController', function () { '../../../../modules/oauth2-server/app/src/OAuthPersonalAccessTokenManager': this.PersonalAccessTokenManager, '../Authentication/SessionManager': this.SessionManager, + '../SplitTests/SplitTestHandler': this.SplitTestHandler, request: (this.request = sinon.stub()), }, })