0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(console): show version number for oss (#5950)

This commit is contained in:
Gao Sun 2024-05-31 18:05:20 +08:00 committed by GitHub
parent 522dd02f44
commit a0b19513bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 210 additions and 155 deletions

View file

@ -0,0 +1,5 @@
---
"@logto/console": minor
---
show version number in the topbar

View file

@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M20.4901 7.52045C20.4844 7.49408 20.4844 7.46681 20.4901 7.44045C20.4852 7.41737 20.4852 7.39353 20.4901 7.37045V7.28045L20.4301 7.13045C20.4057 7.08952 20.3754 7.05244 20.3401 7.02045L20.2501 6.94045H20.2001L16.2601 4.45045L12.5401 2.15045C12.454 2.08218 12.3555 2.03124 12.2501 2.00045H12.1701C12.0807 1.98553 11.9894 1.98553 11.9001 2.00045H11.8001C11.6839 2.02614 11.5725 2.07001 11.4701 2.13045L4.00007 6.78045L3.91007 6.85045L3.82007 6.93045L3.72007 7.00045L3.67007 7.06045L3.61007 7.21045V7.30045V7.36045C3.60035 7.42676 3.60035 7.49413 3.61007 7.56045V16.2904C3.60973 16.4604 3.6527 16.6276 3.73494 16.7763C3.81717 16.9251 3.93596 17.0504 4.08007 17.1404L11.5801 21.7804L11.7301 21.8404H11.8101C11.9792 21.8941 12.1609 21.8941 12.3301 21.8404H12.4101L12.5601 21.7804L20.0001 17.2104C20.1442 17.1204 20.263 16.9951 20.3452 16.8463C20.4274 16.6976 20.4704 16.5304 20.4701 16.3604V7.63045C20.4701 7.63045 20.4901 7.56045 20.4901 7.52045ZM12.0001 4.17045L13.7801 5.27045L8.19007 8.73045L6.40007 7.63045L12.0001 4.17045ZM11.0001 19.1704L5.50007 15.8104V9.42045L11.0001 12.8204V19.1704ZM12.0001 11.0604L10.0901 9.91045L15.6801 6.44045L17.6001 7.63045L12.0001 11.0604ZM18.5001 15.7804L13.0001 19.2004V12.8204L18.5001 9.42045V15.7804Z" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,38 +0,0 @@
@use '@/scss/underscore' as _;
.helpButton {
display: flex;
align-items: center;
padding: _.unit(1);
border-radius: 6px;
border: none;
background-color: none;
transition: background-color 0.2s ease-in-out;
user-select: none;
outline: none;
cursor: pointer;
gap: _.unit(1);
margin-left: _.unit(-1);
&:hover {
background-color: var(--color-hover-variant);
}
&.active {
background-color: var(--color-focused-variant);
}
.icon {
width: 20px;
height: 20px;
> path {
fill: var(--color-neutral-variant-50);
}
}
span {
font: var(--font-label-2);
color: var(--color-neutral-variant-30);
}
}

View file

@ -1,43 +0,0 @@
import { useState, useRef } from 'react';
import ContactIcon from '@/assets/icons/contact-us.svg';
import DynamicT from '@/ds-components/DynamicT';
import { onKeyDownHandler } from '@/utils/a11y';
import ContactModal from './ContactModal';
import * as styles from './index.module.scss';
function Contact() {
const [isContactOpen, setIsContactOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
return (
<>
<div
ref={anchorRef}
tabIndex={0}
className={styles.helpButton}
role="button"
onKeyDown={onKeyDownHandler(() => {
setIsContactOpen(true);
})}
onClick={() => {
setIsContactOpen(true);
}}
>
<ContactIcon className={styles.icon} />
<span>
<DynamicT forKey="topbar.help" />
</span>
</div>
<ContactModal
isOpen={isContactOpen}
onCancel={() => {
setIsContactOpen(false);
}}
/>
</>
);
}
export default Contact;

View file

@ -1,43 +0,0 @@
@use '@/scss/underscore' as _;
.documentNavButton {
display: flex;
align-items: center;
padding: _.unit(1);
border-radius: 6px;
border: none;
background-color: transparent;
transition: background-color 0.2s ease-in-out;
user-select: none;
outline: none;
cursor: pointer;
margin-left: _.unit(-1);
&:hover {
background-color: var(--color-hover-variant);
}
&.active {
background-color: var(--color-focused-variant);
}
.icon {
width: 20px;
height: 20px;
> path {
fill: var(--color-neutral-variant-50);
}
}
span {
font: var(--font-label-2);
color: var(--color-neutral-variant-30);
}
}
.textLink {
&:not(:disabled):hover {
text-decoration: none;
}
}

View file

@ -1,26 +0,0 @@
import classNames from 'classnames';
import DocumentIcon from '@/assets/icons/document-nav-button.svg';
import DynamicT from '@/ds-components/DynamicT';
import TextLink from '@/ds-components/TextLink';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import * as styles from './index.module.scss';
function DocumentNavButton() {
const { documentationSiteUrl } = useDocumentationUrl();
return (
<TextLink
href={documentationSiteUrl}
targetBlank="noopener"
className={classNames(styles.textLink, styles.documentNavButton)}
icon={<DocumentIcon className={styles.icon} />}
>
<span>
<DynamicT forKey="topbar.docs" />
</span>
</TextLink>
);
}
export default DocumentNavButton;

View file

@ -28,3 +28,51 @@
margin-right: _.unit(4); margin-right: _.unit(4);
} }
} }
.button {
display: flex;
align-items: center;
padding: _.unit(1);
border-radius: 6px;
border: none;
background-color: transparent;
transition: background-color 0.2s ease-in-out;
user-select: none;
outline: none;
cursor: pointer;
margin-left: _.unit(-1);
text-decoration: none;
gap: _.unit(1);
font: var(--font-label-2);
color: var(--color-neutral-variant-30);
&:hover {
background-color: var(--color-hover-variant);
}
// Use this selector to override the hover effect in `<TextLink />`
&:not(:disabled):hover {
text-decoration: none;
}
&.active {
background-color: var(--color-focused-variant);
}
.icon {
width: 20px;
height: 20px;
> path {
fill: var(--color-neutral-variant-50);
}
}
}
.newVersionDot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--color-error);
margin-left: _.unit(0.5);
}

View file

@ -1,16 +1,26 @@
import { isKeyInObject, trySafe } from '@silverhand/essentials';
import classNames from 'classnames'; import classNames from 'classnames';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import ContactIcon from '@/assets/icons/contact-us.svg';
import CubeIcon from '@/assets/icons/cube.svg';
import DocumentIcon from '@/assets/icons/document-nav-button.svg';
import CloudLogo from '@/assets/images/cloud-logo.svg'; import CloudLogo from '@/assets/images/cloud-logo.svg';
import Logo from '@/assets/images/logo.svg'; import Logo from '@/assets/images/logo.svg';
import { githubReleasesLink } from '@/consts';
import { isCloud } from '@/consts/env'; import { isCloud } from '@/consts/env';
import DynamicT from '@/ds-components/DynamicT';
import Spacer from '@/ds-components/Spacer'; import Spacer from '@/ds-components/Spacer';
import TextLink from '@/ds-components/TextLink';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import { onKeyDownHandler } from '@/utils/a11y';
import Contact from './Contact'; import ContactModal from './ContactModal';
import DocumentNavButton from './DocumentNavButton';
import TenantSelector from './TenantSelector'; import TenantSelector from './TenantSelector';
import UserInfo from './UserInfo'; import UserInfo from './UserInfo';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
import { currentVersion, isGreaterThanCurrentVersion } from './utils';
type Props = { type Props = {
readonly className?: string; readonly className?: string;
@ -35,11 +45,97 @@ function Topbar({ className, hideTenantSelector, hideTitle }: Props) {
</> </>
)} )}
<Spacer /> <Spacer />
<DocumentNavButton /> <DocumentButton />
<Contact /> <HelpButton />
{!isCloud && <VersionButton />}
<UserInfo /> <UserInfo />
</div> </div>
); );
} }
export default Topbar; export default Topbar;
function DocumentButton() {
const { documentationSiteUrl } = useDocumentationUrl();
return (
<TextLink
href={documentationSiteUrl}
targetBlank="noopener"
className={styles.button}
icon={<DocumentIcon className={styles.icon} />}
>
<DynamicT forKey="topbar.docs" />
</TextLink>
);
}
function HelpButton() {
const [isContactOpen, setIsContactOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
return (
<>
<div
ref={anchorRef}
tabIndex={0}
className={styles.button}
role="button"
onKeyDown={onKeyDownHandler(() => {
setIsContactOpen(true);
})}
onClick={() => {
setIsContactOpen(true);
}}
>
<ContactIcon className={styles.icon} />
<span>
<DynamicT forKey="topbar.help" />
</span>
</div>
<ContactModal
isOpen={isContactOpen}
onCancel={() => {
setIsContactOpen(false);
}}
/>
</>
);
}
function VersionButton() {
const [isNewVersionAvailable, setIsNewVersionAvailable] = useState(false);
useEffect(() => {
void trySafe(
async () => {
const response = await fetch('https://numbers.logto.io/pull.json');
const json = await response.json();
if (
!isKeyInObject(json, 'latestRelease') ||
typeof json.latestRelease !== 'string' ||
!json.latestRelease.startsWith('v')
) {
return;
}
if (isGreaterThanCurrentVersion(json.latestRelease)) {
setIsNewVersionAvailable(true);
}
},
(error) => {
console.warn('Failed to check for new version', error);
}
);
}, []);
return (
<TextLink
href={githubReleasesLink}
targetBlank="noopener"
className={styles.button}
icon={<CubeIcon className={styles.icon} />}
>
v{currentVersion}
{isNewVersionAvailable && <div className={styles.newVersionDot} />}
</TextLink>
);
}

View file

@ -0,0 +1,28 @@
import { version as currentVersion } from '../../../../core/package.json';
import { isGreaterThanCurrentVersion } from './utils';
describe('isGreaterThanCurrentVersion', () => {
it('should return true if the target version is greater than the current Logto version', () => {
expect(isGreaterThanCurrentVersion('v10.0')).toBe(true);
expect(isGreaterThanCurrentVersion('10.0')).toBe(true);
expect(isGreaterThanCurrentVersion('v1.999.0')).toBe(true);
expect(isGreaterThanCurrentVersion('1.999.0')).toBe(true);
});
it('should return false if the target version is less than the current Logto version', () => {
expect(isGreaterThanCurrentVersion('v0.1.1')).toBe(false);
expect(isGreaterThanCurrentVersion('0.1.1')).toBe(false);
expect(isGreaterThanCurrentVersion('v1.15')).toBe(false);
expect(isGreaterThanCurrentVersion('1.15')).toBe(false);
});
it('should return false if the target version is malformed', () => {
expect(isGreaterThanCurrentVersion('vv8')).toBe(false);
expect(isGreaterThanCurrentVersion('foo')).toBe(false);
});
it('should return false if the target version is equal to the current Logto version', () => {
expect(isGreaterThanCurrentVersion(currentVersion)).toBe(false);
});
});

View file

@ -0,0 +1,21 @@
import { version } from '../../../../core/package.json';
export { version as currentVersion } from '../../../../core/package.json';
/** Check if the target version is greater than the current Logto version. */
export const isGreaterThanCurrentVersion = (target: string) => {
const latestComponents = (target.startsWith('v') ? target.slice(1) : target).split('.');
const currentComponents = version.split('.');
for (const [index, component] of latestComponents.entries()) {
const current = currentComponents[index];
if (!current || Number(current) < Number(component)) {
return true;
}
if (Number(current) > Number(component)) {
return false;
}
}
return false;
};

View file

@ -2,6 +2,7 @@ export const discordLink = 'https://discord.gg/UEPaF3j5e6';
export const githubOrgLink = 'https://github.com/logto-io'; export const githubOrgLink = 'https://github.com/logto-io';
export const githubLink = 'https://github.com/logto-io/logto'; export const githubLink = 'https://github.com/logto-io/logto';
export const githubIssuesLink = 'https://github.com/logto-io/logto/issues'; export const githubIssuesLink = 'https://github.com/logto-io/logto/issues';
export const githubReleasesLink = 'https://github.com/logto-io/logto/releases';
export const contactEmail = 'contact@logto.io'; export const contactEmail = 'contact@logto.io';
export const contactEmailLink = `mailto:${contactEmail}`; export const contactEmailLink = `mailto:${contactEmail}`;
export const reservationLink = 'https://cal.com/logto/30min'; export const reservationLink = 'https://cal.com/logto/30min';

View file

@ -37,6 +37,7 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
const adminOrigins = isCloud ? cloudUrlSet.origins : adminUrlSet.origins; const adminOrigins = isCloud ? cloudUrlSet.origins : adminUrlSet.origins;
const coreOrigins = urlSet.origins; const coreOrigins = urlSet.origins;
const developmentOrigins = conditionalArray(!isProduction && 'ws:'); const developmentOrigins = conditionalArray(!isProduction && 'ws:');
const logtoOrigin = 'https://*.logto.io';
// We use react-monaco-editor for code editing in the admin console. It loads the monaco editor asynchronously from a CDN. // We use react-monaco-editor for code editing in the admin console. It loads the monaco editor asynchronously from a CDN.
// Allow the CDN src in the CSP. // Allow the CDN src in the CSP.
@ -116,7 +117,7 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
...conditionalArray(!isProduction && ["'unsafe-eval'", "'unsafe-inline'"]), ...conditionalArray(!isProduction && ["'unsafe-eval'", "'unsafe-inline'"]),
...monacoEditorCDNSource, ...monacoEditorCDNSource,
], ],
connectSrc: ["'self'", ...adminOrigins, ...coreOrigins, ...developmentOrigins], connectSrc: ["'self'", logtoOrigin, ...adminOrigins, ...coreOrigins, ...developmentOrigins],
// Allow Main Flow origin loaded in preview iframe // Allow Main Flow origin loaded in preview iframe
frameSrc: ["'self'", ...adminOrigins, ...coreOrigins], frameSrc: ["'self'", ...adminOrigins, ...coreOrigins],
}, },