From f3baaf919f49fbf27665ea1c5775aac29128e7cd Mon Sep 17 00:00:00 2001 From: Darcy Ye Date: Thu, 15 Dec 2022 15:18:49 +0800 Subject: [PATCH 1/7] fix: connector metadata update should be optional (#2666) --- packages/core/src/routes/connector.ts | 6 ++- .../core/src/routes/connector.update.test.ts | 49 ++++++++++++++++++- .../src/tests/api/connector.test.ts | 4 +- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/core/src/routes/connector.ts b/packages/core/src/routes/connector.ts index a7ed428ff..b1b99af43 100644 --- a/packages/core/src/routes/connector.ts +++ b/packages/core/src/routes/connector.ts @@ -208,12 +208,14 @@ export default function connectorRoutes(router: T) { const { type, validateConfig, metadata: originalMetadata } = await getLogtoConnectorById(id); assertThat( - originalMetadata.isStandard !== true || metadata?.target === originalMetadata.target, + originalMetadata.isStandard !== true || + !metadata || + metadata.target === originalMetadata.target, 'connector.can_not_modify_target' ); assertThat( - originalMetadata.isStandard === true || metadata === undefined, + originalMetadata.isStandard === true || !metadata, 'connector.cannot_overwrite_metadata_for_non_standard_connector' ); diff --git a/packages/core/src/routes/connector.update.test.ts b/packages/core/src/routes/connector.update.test.ts index e91c8af84..3a46d022d 100644 --- a/packages/core/src/routes/connector.update.test.ts +++ b/packages/core/src/routes/connector.update.test.ts @@ -108,7 +108,54 @@ describe('connector PATCH routes', () => { expect(response).toHaveProperty('statusCode', 400); }); - it('successfully updates connector configs', async () => { + it('throws when updates non-standard connector metadata', async () => { + getLogtoConnectors.mockResolvedValue([ + { + dbEntry: mockConnector, + metadata: { ...mockMetadata }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + const response = await connectorRequest.patch('/connectors/id').send({ + metadata: { + target: 'connector', + name: { en: 'connector_name', fr: 'connector_name' }, + logo: 'new_logo.png', + }, + }); + expect(response).toHaveProperty('statusCode', 400); + }); + + it('successfully updates connector config', async () => { + getLogtoConnectors.mockResolvedValue([ + { + dbEntry: mockConnector, + metadata: { ...mockMetadata, isStandard: true }, + type: ConnectorType.Social, + ...mockLogtoConnector, + }, + ]); + updateConnector.mockResolvedValueOnce({ + ...mockConnector, + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + const response = await connectorRequest.patch('/connectors/id').send({ + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }); + expect(response).toHaveProperty('statusCode', 200); + expect(updateConnector).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'id' }, + set: { + config: { cliend_id: 'client_id', client_secret: 'client_secret' }, + }, + jsonbMode: 'replace', + }) + ); + }); + + it('successfully updates connector config and metadata', async () => { getLogtoConnectors.mockResolvedValue([ { dbEntry: mockConnector, diff --git a/packages/integration-tests/src/tests/api/connector.test.ts b/packages/integration-tests/src/tests/api/connector.test.ts index 33ad7b12d..ea2d711ff 100644 --- a/packages/integration-tests/src/tests/api/connector.test.ts +++ b/packages/integration-tests/src/tests/api/connector.test.ts @@ -77,9 +77,7 @@ test('connector set-up flow', async () => { connectorId: mockStandardEmailConnectorId, metadata: { target: 'mock-standard-mail' }, }); - await updateConnectorConfig(id, mockStandardEmailConnectorConfig, { - target: 'mock-standard-mail', - }); + await updateConnectorConfig(id, mockStandardEmailConnectorConfig); connectorIdMap.set(mockStandardEmailConnectorId, id); const currentConnectors = await listConnectors(); expect( From bc5f4b541adbcc3a02303c4b4e8673f3c2226463 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 15 Dec 2022 17:04:42 +0800 Subject: [PATCH 2/7] refactor: replace `termsOfUse` with `termsOfUseUrl` (#2665) --- .../tabs/Others/TermsForm.tsx | 35 +++----- .../src/pages/SignInExperience/utils/form.ts | 11 +-- .../core/src/__mocks__/sign-in-experience.ts | 80 ++++++++--------- .../sign-in-experience/index.test.ts | 18 +--- .../src/libraries/sign-in-experience/index.ts | 10 +-- .../src/queries/sign-in-experience.test.ts | 14 ++- .../routes/sign-in-experience.guard.test.ts | 43 ++-------- .../src/routes/sign-in-experience.test.ts | 29 +++---- .../core/src/routes/sign-in-experience.ts | 17 ++-- packages/core/src/routes/well-known.test.ts | 1 + .../src/tests/api/sign-in-experience.test.ts | 5 +- .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../translation/admin-console/sign-in-exp.ts | 2 - .../next-1671080370-terms-of-use.ts | 86 +++++++++++++++++++ .../schemas/src/foundations/jsonb-types.ts | 7 -- .../schemas/src/seeds/sign-in-experience.ts | 4 +- .../schemas/tables/sign_in_experiences.sql | 2 +- packages/ui/src/__mocks__/logto.tsx | 7 +- .../TermsOfUseConfirmModalContent/index.tsx | 7 +- .../ui/src/containers/TermsOfUse/index.tsx | 6 +- packages/ui/src/hooks/use-terms.ts | 12 +-- .../ui/src/utils/sign-in-experience.test.ts | 2 +- 28 files changed, 196 insertions(+), 216 deletions(-) create mode 100644 packages/schemas/alterations/next-1671080370-terms-of-use.ts diff --git a/packages/console/src/pages/SignInExperience/tabs/Others/TermsForm.tsx b/packages/console/src/pages/SignInExperience/tabs/Others/TermsForm.tsx index 7c1727f1d..095905915 100644 --- a/packages/console/src/pages/SignInExperience/tabs/Others/TermsForm.tsx +++ b/packages/console/src/pages/SignInExperience/tabs/Others/TermsForm.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'; import Card from '@/components/Card'; import FormField from '@/components/FormField'; -import Switch from '@/components/Switch'; import TextInput from '@/components/TextInput'; import { uriValidator } from '@/utilities/validator'; @@ -13,38 +12,26 @@ import * as styles from '../index.module.scss'; const TermsForm = () => { const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { - watch, register, formState: { errors }, } = useFormContext(); - const enabled = watch('termsOfUse.enabled'); return (
{t('sign_in_exp.others.terms_of_use.title')}
- - + !value || uriValidator(value) || t('errors.invalid_uri_format'), + })} + hasError={Boolean(errors.termsOfUseUrl)} + errorMessage={errors.termsOfUseUrl?.message} + placeholder={t('sign_in_exp.others.terms_of_use.terms_of_use_placeholder')} /> - {enabled && ( - - !value || uriValidator(value) || t('errors.invalid_uri_format'), - })} - hasError={Boolean(errors.termsOfUse)} - errorMessage={errors.termsOfUse?.contentUrl?.message} - placeholder={t('sign_in_exp.others.terms_of_use.terms_of_use_placeholder')} - /> - - )}
); }; diff --git a/packages/console/src/pages/SignInExperience/utils/form.ts b/packages/console/src/pages/SignInExperience/utils/form.ts index 65fb0c653..c97404550 100644 --- a/packages/console/src/pages/SignInExperience/utils/form.ts +++ b/packages/console/src/pages/SignInExperience/utils/form.ts @@ -113,12 +113,5 @@ export const getSignUpAndSignInErrorCount = ( return signUpErrorCount + signInMethodErrorCount; }; -export const getOthersErrorCount = ( - errors: FieldErrorsImpl> -) => { - const { termsOfUse } = errors; - - const termsOfUseErrorCount = termsOfUse ? Object.keys(termsOfUse).length : 0; - - return termsOfUseErrorCount; -}; +export const getOthersErrorCount = (errors: FieldErrorsImpl>) => + errors.termsOfUseUrl ? 1 : 0; diff --git a/packages/core/src/__mocks__/sign-in-experience.ts b/packages/core/src/__mocks__/sign-in-experience.ts index 6b9a702a4..f464aba13 100644 --- a/packages/core/src/__mocks__/sign-in-experience.ts +++ b/packages/core/src/__mocks__/sign-in-experience.ts @@ -2,13 +2,48 @@ import type { Branding, LanguageInfo, SignInExperience, - TermsOfUse, Color, SignUp, SignIn, } from '@logto/schemas'; import { BrandingStyle, SignInMode, SignInIdentifier } from '@logto/schemas'; +export const mockColor: Color = { + primaryColor: '#000', + isDarkModeEnabled: true, + darkPrimaryColor: '#fff', +}; + +export const mockBranding: Branding = { + style: BrandingStyle.Logo_Slogan, + logoUrl: 'http://silverhand.png', + slogan: 'Silverhand.', +}; + +export const mockTermsOfUseUrl = 'http://silverhand.com/terms'; + +export const mockLanguageInfo: LanguageInfo = { + autoDetect: true, + fallbackLanguage: 'en', +}; + +export const mockSignUp: SignUp = { + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, +}; + +export const mockSignInMethod: SignIn['methods'][0] = { + identifier: SignInIdentifier.Username, + password: true, + verificationCode: false, + isPasswordPrimary: true, +}; + +export const mockSignIn = { + methods: [mockSignInMethod], +}; + export const mockSignInExperience: SignInExperience = { id: 'foo', color: { @@ -21,9 +56,7 @@ export const mockSignInExperience: SignInExperience = { logoUrl: 'http://logto.png', slogan: 'logto', }, - termsOfUse: { - enabled: false, - }, + termsOfUseUrl: mockTermsOfUseUrl, languageInfo: { autoDetect: true, fallbackLanguage: 'en', @@ -58,42 +91,3 @@ export const mockSignInExperience: SignInExperience = { socialSignInConnectorTargets: ['github', 'facebook', 'wechat'], signInMode: SignInMode.SignInAndRegister, }; - -export const mockColor: Color = { - primaryColor: '#000', - isDarkModeEnabled: true, - darkPrimaryColor: '#fff', -}; - -export const mockBranding: Branding = { - style: BrandingStyle.Logo_Slogan, - logoUrl: 'http://silverhand.png', - slogan: 'Silverhand.', -}; - -export const mockTermsOfUse: TermsOfUse = { - enabled: true, - contentUrl: 'http://silverhand.com/terms', -}; - -export const mockLanguageInfo: LanguageInfo = { - autoDetect: true, - fallbackLanguage: 'en', -}; - -export const mockSignUp: SignUp = { - identifiers: [SignInIdentifier.Username], - password: true, - verify: false, -}; - -export const mockSignInMethod: SignIn['methods'][0] = { - identifier: SignInIdentifier.Username, - password: true, - verificationCode: false, - isPasswordPrimary: true, -}; - -export const mockSignIn = { - methods: [mockSignInMethod], -}; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index b41930a41..42a5f95e3 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -35,12 +35,8 @@ const { findDefaultSignInExperience, updateDefaultSignInExperience } = mockEsm( }) ); -const { - validateBranding, - validateTermsOfUse, - validateLanguageInfo, - removeUnavailableSocialConnectorTargets, -} = await import('./index.js'); +const { validateBranding, validateLanguageInfo, removeUnavailableSocialConnectorTargets } = + await import('./index.js'); beforeEach(() => { jest.clearAllMocks(); @@ -139,16 +135,6 @@ describe('validate language info', () => { }); }); -describe('validate terms of use', () => { - test('should throw when terms of use is enabled and content URL is empty', () => { - expect(() => { - validateTermsOfUse({ - enabled: true, - }); - }).toMatchError(new RequestError('sign_in_experiences.empty_content_url_of_terms_of_use')); - }); -}); - describe('remove unavailable social connector targets', () => { test('should remove unavailable social connector targets in sign-in experience', async () => { const mockSocialConnectorTargets = mockSocialConnectors.map( diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 2f81eb6c3..76e925819 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -1,5 +1,5 @@ import { builtInLanguages } from '@logto/phrases-ui'; -import type { Branding, LanguageInfo, SignInExperience, TermsOfUse } from '@logto/schemas'; +import type { Branding, LanguageInfo, SignInExperience } from '@logto/schemas'; import { SignInMode, ConnectorType, BrandingStyle } from '@logto/schemas'; import { adminConsoleApplicationId, @@ -42,13 +42,6 @@ export const validateLanguageInfo = async (languageInfo: LanguageInfo) => { ); }; -export const validateTermsOfUse = (termsOfUse: TermsOfUse) => { - assertThat( - !termsOfUse.enabled || termsOfUse.contentUrl, - 'sign_in_experiences.empty_content_url_of_terms_of_use' - ); -}; - export const removeUnavailableSocialConnectorTargets = async () => { const connectors = await getLogtoConnectors(); const availableSocialConnectorTargets = deduplicate( @@ -78,6 +71,7 @@ export const getSignInExperienceForApplication = async ( ...adminConsoleSignInExperience.branding, slogan: i18next.t('admin_console.welcome.title'), }, + termsOfUseUrl: signInExperience.termsOfUseUrl, languageInfo: signInExperience.languageInfo, signInMode: (await hasActiveUsers()) ? SignInMode.SignIn : SignInMode.Register, socialSignInConnectorTargets: [], diff --git a/packages/core/src/queries/sign-in-experience.test.ts b/packages/core/src/queries/sign-in-experience.test.ts index f2977c95a..ec1319a4c 100644 --- a/packages/core/src/queries/sign-in-experience.test.ts +++ b/packages/core/src/queries/sign-in-experience.test.ts @@ -28,7 +28,7 @@ describe('sign-in-experience query', () => { ...mockSignInExperience, color: JSON.stringify(mockSignInExperience.color), branding: JSON.stringify(mockSignInExperience.branding), - termsOfUse: JSON.stringify(mockSignInExperience.termsOfUse), + termsOfUseUrl: mockSignInExperience.termsOfUseUrl, languageInfo: JSON.stringify(mockSignInExperience.languageInfo), signIn: JSON.stringify(mockSignInExperience.signIn), signUp: JSON.stringify(mockSignInExperience.signUp), @@ -38,7 +38,7 @@ describe('sign-in-experience query', () => { it('findDefaultSignInExperience', async () => { /* eslint-disable sql/no-unsafe-query */ const expectSql = ` - select "id", "color", "branding", "language_info", "terms_of_use", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode" + select "id", "color", "branding", "language_info", "terms_of_use_url", "sign_in", "sign_up", "social_sign_in_connector_targets", "sign_in_mode" from "sign_in_experiences" where "id"=$1 `; @@ -55,14 +55,12 @@ describe('sign-in-experience query', () => { }); it('updateDefaultSignInExperience', async () => { - const termsOfUse = { - enabled: false, - }; + const { termsOfUseUrl } = mockSignInExperience; /* eslint-disable sql/no-unsafe-query */ const expectSql = ` update "sign_in_experiences" - set "terms_of_use"=$1 + set "terms_of_use_url"=$1 where "id"=$2 returning * `; @@ -70,11 +68,11 @@ describe('sign-in-experience query', () => { mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql); - expect(values).toEqual([JSON.stringify(termsOfUse), id]); + expect(values).toEqual([termsOfUseUrl, id]); return createMockQueryResult([dbvalue]); }); - await expect(updateDefaultSignInExperience({ termsOfUse })).resolves.toEqual(dbvalue); + await expect(updateDefaultSignInExperience({ termsOfUseUrl })).resolves.toEqual(dbvalue); }); }); diff --git a/packages/core/src/routes/sign-in-experience.guard.test.ts b/packages/core/src/routes/sign-in-experience.guard.test.ts index 6f8f60466..2d99bf5e0 100644 --- a/packages/core/src/routes/sign-in-experience.guard.test.ts +++ b/packages/core/src/routes/sign-in-experience.guard.test.ts @@ -9,7 +9,6 @@ import { mockGoogleConnector, mockLanguageInfo, mockSignInExperience, - mockTermsOfUse, } from '#src/__mocks__/index.js'; const { jest } = import.meta; @@ -59,44 +58,20 @@ beforeEach(() => { jest.clearAllMocks(); }); -describe('terms of use', () => { - describe('enabled', () => { - test.each(validBooleans)('%p should success', async (enabled) => { - const signInExperience = { termsOfUse: { ...mockTermsOfUse, enabled } }; - await expectPatchResponseStatus(signInExperience, 200); - }); - - test.each(invalidBooleans)('%p should fail', async (enabled) => { - const signInExperience = { termsOfUse: { ...mockTermsOfUse, enabled } }; - await expectPatchResponseStatus(signInExperience, 400); - }); - }); - - describe('contentUrl', () => { - test.each([undefined, 'http://silverhand.com/terms', 'https://logto.dev/terms'])( +describe('terms of use url', () => { + describe('termsOfUseUrl', () => { + test.each([undefined, null, '', 'http://silverhand.com/terms', 'https://logto.dev/terms'])( '%p should success', - async (contentUrl) => { - const signInExperience = { termsOfUse: { ...mockTermsOfUse, enabled: false, contentUrl } }; + async (termsOfUseUrl) => { + const signInExperience = { + termsOfUseUrl, + }; await expectPatchResponseStatus(signInExperience, 200); } ); - test.each([null, ' \t\n\r', 'non-url'])('%p should fail', async (contentUrl) => { - const signInExperience = { termsOfUse: { ...mockTermsOfUse, enabled: false, contentUrl } }; - await expectPatchResponseStatus(signInExperience, 400); - }); - - test('should allow empty contentUrl if termsOfUse is disabled', async () => { - const signInExperience = { - termsOfUse: { ...mockTermsOfUse, enabled: false, contentUrl: '' }, - }; - await expectPatchResponseStatus(signInExperience, 200); - }); - - test('should not allow empty contentUrl if termsOfUse is enabled', async () => { - const signInExperience = { - termsOfUse: { ...mockTermsOfUse, enabled: true, contentUrl: '' }, - }; + test.each([' \t\n\r', 'non-url'])('%p should fail', async (termsOfUseUrl) => { + const signInExperience = { termsOfUseUrl }; await expectPatchResponseStatus(signInExperience, 400); }); }); diff --git a/packages/core/src/routes/sign-in-experience.test.ts b/packages/core/src/routes/sign-in-experience.test.ts index d684e85e1..03c63523f 100644 --- a/packages/core/src/routes/sign-in-experience.test.ts +++ b/packages/core/src/routes/sign-in-experience.test.ts @@ -1,4 +1,4 @@ -import type { SignInExperience, CreateSignInExperience, TermsOfUse } from '@logto/schemas'; +import type { SignInExperience, CreateSignInExperience } from '@logto/schemas'; import { mockEsm, mockEsmWithActual, pickDefault } from '@logto/shared/esm'; import { @@ -13,24 +13,19 @@ import { mockSignIn, mockLanguageInfo, mockAliyunSmsConnector, + mockTermsOfUseUrl, } from '#src/__mocks__/index.js'; import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; -const { - validateBranding, - validateLanguageInfo, - validateTermsOfUse, - validateSignIn, - validateSignUp, -} = await mockEsmWithActual('#src/libraries/sign-in-experience/index.js', () => ({ - validateBranding: jest.fn(), - validateLanguageInfo: jest.fn(), - validateTermsOfUse: jest.fn(), - validateSignIn: jest.fn(), - validateSignUp: jest.fn(), -})); +const { validateBranding, validateLanguageInfo, validateSignIn, validateSignUp } = + await mockEsmWithActual('#src/libraries/sign-in-experience/index.js', () => ({ + validateBranding: jest.fn(), + validateLanguageInfo: jest.fn(), + validateSignIn: jest.fn(), + validateSignUp: jest.fn(), + })); const logtoConnectors = [ mockFacebookConnector, @@ -106,14 +101,13 @@ describe('PATCH /sign-in-exp', () => { }); it('should succeed to update when the input is valid', async () => { - const termsOfUse: TermsOfUse = { enabled: false }; const socialSignInConnectorTargets = ['github', 'facebook', 'wechat']; const response = await signInExperienceRequester.patch('/sign-in-exp').send({ color: mockColor, branding: mockBranding, languageInfo: mockLanguageInfo, - termsOfUse, + termsOfUseUrl: mockTermsOfUseUrl, socialSignInConnectorTargets, signUp: mockSignUp, signIn: mockSignIn, @@ -121,7 +115,6 @@ describe('PATCH /sign-in-exp', () => { expect(validateBranding).toHaveBeenCalledWith(mockBranding); expect(validateLanguageInfo).toHaveBeenCalledWith(mockLanguageInfo); - expect(validateTermsOfUse).toHaveBeenCalledWith(termsOfUse); expect(validateSignUp).toHaveBeenCalledWith(mockSignUp, logtoConnectors); expect(validateSignIn).toHaveBeenCalledWith(mockSignIn, mockSignUp, logtoConnectors); @@ -131,7 +124,7 @@ describe('PATCH /sign-in-exp', () => { ...mockSignInExperience, color: mockColor, branding: mockBranding, - termsOfUse, + termsOfUseUrl: mockTermsOfUseUrl, socialSignInConnectorTargets, signIn: mockSignIn, }, diff --git a/packages/core/src/routes/sign-in-experience.ts b/packages/core/src/routes/sign-in-experience.ts index 944dd4605..ea7b421c5 100644 --- a/packages/core/src/routes/sign-in-experience.ts +++ b/packages/core/src/routes/sign-in-experience.ts @@ -1,10 +1,10 @@ import { ConnectorType, SignInExperiences } from '@logto/schemas'; +import { literal, object, string } from 'zod'; import { getLogtoConnectors } from '#src/connectors/index.js'; import { validateBranding, validateLanguageInfo, - validateTermsOfUse, validateSignUp, validateSignIn, } from '#src/libraries/sign-in-experience/index.js'; @@ -30,11 +30,18 @@ export default function signInExperiencesRoutes(router: router.patch( '/sign-in-exp', koaGuard({ - body: SignInExperiences.createGuard.omit({ id: true }).partial(), + body: SignInExperiences.createGuard + .omit({ id: true, termsOfUseUrl: true }) + .merge( + object({ + termsOfUseUrl: string().url().optional().nullable().or(literal('')), + }) + ) + .partial(), }), async (ctx, next) => { const { socialSignInConnectorTargets, ...rest } = ctx.guard.body; - const { branding, languageInfo, termsOfUse, signUp, signIn } = rest; + const { branding, languageInfo, signUp, signIn } = rest; if (branding) { validateBranding(branding); @@ -44,10 +51,6 @@ export default function signInExperiencesRoutes(router: await validateLanguageInfo(languageInfo); } - if (termsOfUse) { - validateTermsOfUse(termsOfUse); - } - const connectors = await getLogtoConnectors(); // Remove unavailable connectors diff --git a/packages/core/src/routes/well-known.test.ts b/packages/core/src/routes/well-known.test.ts index 33c66db9d..740f6115c 100644 --- a/packages/core/src/routes/well-known.test.ts +++ b/packages/core/src/routes/well-known.test.ts @@ -108,6 +108,7 @@ describe('GET /.well-known/sign-in-exp', () => { ...adminConsoleSignInExperience.branding, slogan: 'admin_console.welcome.title', }, + termsOfUseUrl: mockSignInExperience.termsOfUseUrl, languageInfo: mockSignInExperience.languageInfo, socialConnectors: [], signInMode: SignInMode.SignIn, diff --git a/packages/integration-tests/src/tests/api/sign-in-experience.test.ts b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts index 2ba6e9025..69c9ba43e 100644 --- a/packages/integration-tests/src/tests/api/sign-in-experience.test.ts +++ b/packages/integration-tests/src/tests/api/sign-in-experience.test.ts @@ -22,10 +22,7 @@ describe('admin console sign-in experience', () => { logoUrl: 'https://logto.io/new-logo.png', darkLogoUrl: 'https://logto.io/new-dark-logo.png', }, - termsOfUse: { - enabled: true, - contentUrl: 'https://logto.io/terms', - }, + termsOfUseUrl: 'https://logto.io/terms', }; const updatedSignInExperience = await updateSignInExperience(newSignInExperience); diff --git a/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts index daa999123..a7ccca366 100644 --- a/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/de/translation/admin-console/sign-in-exp.ts @@ -98,8 +98,6 @@ const sign_in_exp = { others: { terms_of_use: { title: 'NUTZUNGSBEDINGUNGEN', - enable: 'Aktiviere Nutzungsbedingungen', - description: 'Füge die rechtlichen Vereinbarungen für die Nutzung deines Produkts hinzu', terms_of_use: 'Nutzungsbedingungen', terms_of_use_placeholder: 'https://beispiel.de/nutzungsbedingungen', terms_of_use_tip: 'URL zu den Nutzungsbedingungen', diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts index 0f35ae8a3..819f8ddbb 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp.ts @@ -96,8 +96,6 @@ const sign_in_exp = { others: { terms_of_use: { title: 'TERMS OF USE', - enable: 'Enable terms of use', - description: 'Add the legal agreements for the use of your product', terms_of_use: 'Terms of use', terms_of_use_placeholder: 'https://your.terms.of.use/', terms_of_use_tip: 'Terms of use URL', diff --git a/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts index f1818c26c..633aee1ee 100644 --- a/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/fr/translation/admin-console/sign-in-exp.ts @@ -98,8 +98,6 @@ const sign_in_exp = { others: { terms_of_use: { title: "CONDITIONS D'UTILISATION", - enable: "Activer les conditions d'utilisation", - description: "Ajouter les accords juridiques pour l'utilisation de votre produit", terms_of_use: "Conditions d'utilisation", terms_of_use_placeholder: 'https://vos.conditions.utilisation/', terms_of_use_tip: "Conditions d'utilisation URL", diff --git a/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts index de1321d9c..4dcf4cc1e 100644 --- a/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/ko/translation/admin-console/sign-in-exp.ts @@ -93,8 +93,6 @@ const sign_in_exp = { others: { terms_of_use: { title: '이용 약관', - enable: '이용 약관 활성화', - description: '서비스 사용을 위한 이용 약관을 추가해보세요.', terms_of_use: '이용 약관', terms_of_use_placeholder: 'https://your.terms.of.use/', terms_of_use_tip: '이용 약관 URL', diff --git a/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts index 416837d00..7b0fa2396 100644 --- a/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/pt-br/translation/admin-console/sign-in-exp.ts @@ -98,8 +98,6 @@ const sign_in_exp = { others: { terms_of_use: { title: 'TERMOS DE USO', - enable: 'Habilitar termos de uso', - description: 'Adicione os acordos legais para o uso do seu produto', terms_of_use: 'Termos de uso', terms_of_use_placeholder: 'https://your.terms.of.use/', terms_of_use_tip: 'URL dos termos de uso', diff --git a/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts index ebf9f708c..76ebed176 100644 --- a/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/pt-pt/translation/admin-console/sign-in-exp.ts @@ -96,8 +96,6 @@ const sign_in_exp = { others: { terms_of_use: { title: 'TERMOS DE USO', - enable: 'Ativar termos de uso', - description: 'Adicione os termos legais para uso do seu produto', terms_of_use: 'Termos de uso', terms_of_use_placeholder: 'https://your.terms.of.use/', terms_of_use_tip: 'URL dos termos de uso', diff --git a/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts index a3854af28..ca50061db 100644 --- a/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/tr-tr/translation/admin-console/sign-in-exp.ts @@ -97,8 +97,6 @@ const sign_in_exp = { others: { terms_of_use: { title: 'KULLANIM KOŞULLARI', - enable: 'Kullanım koşullarını etkinleştir', - description: 'Ürününüzün kullanımına ilişkin yasal anlaşmaları ekleyin', terms_of_use: 'Kullanım koşulları', terms_of_use_placeholder: 'https://your.terms.of.use/', terms_of_use_tip: 'Kullanım koşulları URLi', diff --git a/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts b/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts index 69bdb8cce..92c5ee972 100644 --- a/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts +++ b/packages/phrases/src/locales/zh-cn/translation/admin-console/sign-in-exp.ts @@ -89,8 +89,6 @@ const sign_in_exp = { others: { terms_of_use: { title: '使用条款', - enable: '开启使用条款', - description: '添加使用产品的法律协议。', terms_of_use: '使用条款', terms_of_use_placeholder: 'https://your.terms.of.use/', terms_of_use_tip: '使用条款 URL', diff --git a/packages/schemas/alterations/next-1671080370-terms-of-use.ts b/packages/schemas/alterations/next-1671080370-terms-of-use.ts new file mode 100644 index 000000000..4f05d31f0 --- /dev/null +++ b/packages/schemas/alterations/next-1671080370-terms-of-use.ts @@ -0,0 +1,86 @@ +import type { DatabaseTransactionConnection } from 'slonik'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +type DeprecatedTermsOfUse = { + enabled: boolean; + contentUrl?: string; +}; + +type DeprecatedSignInExperience = { + id: string; + termsOfUse: DeprecatedTermsOfUse; +}; + +type SignInExperience = { + id: string; + termsOfUseUrl?: string | null; +}; + +const alterTermsOfUse = async ( + signInExperience: DeprecatedSignInExperience, + pool: DatabaseTransactionConnection +) => { + const { + id, + termsOfUse: { enabled, contentUrl }, + } = signInExperience; + + if (enabled && contentUrl) { + await pool.query( + sql`update sign_in_experiences set terms_of_use_url = ${contentUrl} where id = ${id}` + ); + } +}; + +const rollbackTermsOfUse = async ( + signInExperience: SignInExperience, + pool: DatabaseTransactionConnection +) => { + const { id, termsOfUseUrl } = signInExperience; + + const termsOfUse: DeprecatedTermsOfUse = { + enabled: Boolean(termsOfUseUrl), + contentUrl: termsOfUseUrl ?? '', + }; + + await pool.query( + sql`update sign_in_experiences set terms_of_use = ${JSON.stringify( + termsOfUse + )} where id = ${id}` + ); +}; + +const alteration: AlterationScript = { + up: async (pool) => { + const rows = await pool.many( + sql`select * from sign_in_experiences` + ); + + await pool.query(sql` + alter table sign_in_experiences add column terms_of_use_url varchar(2048) + `); + + await Promise.all(rows.map(async (row) => alterTermsOfUse(row, pool))); + + await pool.query(sql` + alter table sign_in_experiences drop column terms_of_use + `); + }, + down: async (pool) => { + const rows = await pool.many(sql`select * from sign_in_experiences`); + + await pool.query(sql` + alter table sign_in_experiences add column terms_of_use jsonb not null default '{}'::jsonb + `); + + await Promise.all(rows.map(async (row) => rollbackTermsOfUse(row, pool))); + + await pool.query(sql` + alter table sign_in_experiences drop column terms_of_use_url + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 224e32578..3d1520ea3 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -119,13 +119,6 @@ export const brandingGuard = z.object({ export type Branding = z.infer; -export const termsOfUseGuard = z.object({ - enabled: z.boolean(), - contentUrl: z.string().url().optional().or(z.literal('')), -}); - -export type TermsOfUse = z.infer; - export const languageInfoGuard = z.object({ autoDetect: z.boolean(), fallbackLanguage: languageTagGuard, diff --git a/packages/schemas/src/seeds/sign-in-experience.ts b/packages/schemas/src/seeds/sign-in-experience.ts index b5123ff64..4ac4a7345 100644 --- a/packages/schemas/src/seeds/sign-in-experience.ts +++ b/packages/schemas/src/seeds/sign-in-experience.ts @@ -22,9 +22,7 @@ export const defaultSignInExperience: Readonly = { autoDetect: true, fallbackLanguage: 'en', }, - termsOfUse: { - enabled: false, - }, + termsOfUseUrl: null, signUp: { identifiers: [SignInIdentifier.Username], password: true, diff --git a/packages/schemas/tables/sign_in_experiences.sql b/packages/schemas/tables/sign_in_experiences.sql index 3c6dc0827..c0f7ce154 100644 --- a/packages/schemas/tables/sign_in_experiences.sql +++ b/packages/schemas/tables/sign_in_experiences.sql @@ -5,7 +5,7 @@ create table sign_in_experiences ( color jsonb /* @use Color */ not null, branding jsonb /* @use Branding */ not null, language_info jsonb /* @use LanguageInfo */ not null, - terms_of_use jsonb /* @use TermsOfUse */ not null, + terms_of_use_url varchar(2048), sign_in jsonb /* @use SignIn */ not null, sign_up jsonb /* @use SignUp */ not null, social_sign_in_connector_targets jsonb /* @use ConnectorTargets */ not null default '[]'::jsonb, diff --git a/packages/ui/src/__mocks__/logto.tsx b/packages/ui/src/__mocks__/logto.tsx index 3ddb84131..fbae28a93 100644 --- a/packages/ui/src/__mocks__/logto.tsx +++ b/packages/ui/src/__mocks__/logto.tsx @@ -192,10 +192,7 @@ export const mockSignInExperience: SignInExperience = { logoUrl: 'http://logto.png', slogan: 'logto', }, - termsOfUse: { - enabled: true, - contentUrl: 'http://terms.of.use/', - }, + termsOfUseUrl: 'http://terms.of.use/', languageInfo: { autoDetect: true, fallbackLanguage: 'en', @@ -216,7 +213,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = { id: mockSignInExperience.id, color: mockSignInExperience.color, branding: mockSignInExperience.branding, - termsOfUse: mockSignInExperience.termsOfUse, + termsOfUseUrl: mockSignInExperience.termsOfUseUrl, languageInfo: mockSignInExperience.languageInfo, signIn: mockSignInExperience.signIn, signUp: { diff --git a/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModalContent/index.tsx b/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModalContent/index.tsx index 2ad598400..7170e9237 100644 --- a/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModalContent/index.tsx +++ b/packages/ui/src/containers/TermsOfUse/TermsOfUseConfirmModalContent/index.tsx @@ -2,6 +2,7 @@ import { useContext } from 'react'; import { useTranslation, Trans } from 'react-i18next'; import TextLink from '@/components/TextLink'; +import type { Props as TextLinkProps } from '@/components/TextLink'; import type { ModalContentRenderProps } from '@/hooks/use-confirm-modal'; import { PageContext } from '@/hooks/use-page-context'; import usePlatform from '@/hooks/use-platform'; @@ -9,19 +10,19 @@ import { ConfirmModalMessage } from '@/types'; const TermsOfUseConfirmModalContent = ({ cancel }: ModalContentRenderProps) => { const { experienceSettings } = useContext(PageContext); - const { termsOfUse } = experienceSettings ?? {}; + const { termsOfUseUrl } = experienceSettings ?? {}; const { t } = useTranslation(); const { isMobile } = usePlatform(); - const linkProps = isMobile + const linkProps: TextLinkProps = isMobile ? { onClick: () => { cancel(ConfirmModalMessage.SHOW_TERMS_DETAIL_MODAL); }, } : { - href: termsOfUse?.contentUrl, + href: termsOfUseUrl ?? undefined, target: '_blank', }; diff --git a/packages/ui/src/containers/TermsOfUse/index.tsx b/packages/ui/src/containers/TermsOfUse/index.tsx index 82ac87227..e43093f4d 100644 --- a/packages/ui/src/containers/TermsOfUse/index.tsx +++ b/packages/ui/src/containers/TermsOfUse/index.tsx @@ -7,11 +7,11 @@ type Props = { }; const TermsOfUse = ({ className }: Props) => { - const { termsAgreement, setTermsAgreement, termsSettings, termsOfUseIframeModalHandler } = + const { termsAgreement, setTermsAgreement, termsOfUseUrl, termsOfUseIframeModalHandler } = useTerms(); const { isMobile } = usePlatform(); - if (!termsSettings?.enabled || !termsSettings.contentUrl) { + if (!termsOfUseUrl) { return null; } @@ -19,7 +19,7 @@ const TermsOfUse = ({ className }: Props) => { { setTermsAgreement(checked); diff --git a/packages/ui/src/hooks/use-terms.ts b/packages/ui/src/hooks/use-terms.ts index a51a4f4e6..603230720 100644 --- a/packages/ui/src/hooks/use-terms.ts +++ b/packages/ui/src/hooks/use-terms.ts @@ -12,12 +12,12 @@ const useTerms = () => { const { termsAgreement, setTermsAgreement, experienceSettings } = useContext(PageContext); const { show } = useConfirmModal(); - const { termsOfUse } = experienceSettings ?? {}; + const { termsOfUseUrl } = experienceSettings ?? {}; const termsOfUseIframeModalHandler = useCallback(async () => { const [result] = await show({ className: styles.iframeModal, - ModalContent: () => createIframeConfirmModalContent(termsOfUse?.contentUrl), + ModalContent: () => createIframeConfirmModalContent(termsOfUseUrl ?? undefined), confirmText: 'action.agree', }); @@ -27,7 +27,7 @@ const useTerms = () => { } return result; - }, [setTermsAgreement, show, termsOfUse?.contentUrl]); + }, [setTermsAgreement, show, termsOfUseUrl]); const termsOfUseConfirmModalHandler = useCallback(async () => { const [result, data] = await show({ @@ -51,15 +51,15 @@ const useTerms = () => { }, [setTermsAgreement, show, termsOfUseIframeModalHandler]); const termsValidation = useCallback(async () => { - if (termsAgreement || !termsOfUse?.enabled || !termsOfUse.contentUrl) { + if (termsAgreement || !termsOfUseUrl) { return true; } return termsOfUseConfirmModalHandler(); - }, [termsAgreement, termsOfUse, termsOfUseConfirmModalHandler]); + }, [termsAgreement, termsOfUseUrl, termsOfUseConfirmModalHandler]); return { - termsSettings: termsOfUse, + termsOfUseUrl, termsAgreement, termsValidation, setTermsAgreement, diff --git a/packages/ui/src/utils/sign-in-experience.test.ts b/packages/ui/src/utils/sign-in-experience.test.ts index 8c7be2dc3..37887ebaa 100644 --- a/packages/ui/src/utils/sign-in-experience.test.ts +++ b/packages/ui/src/utils/sign-in-experience.test.ts @@ -16,7 +16,7 @@ describe('getSignInExperienceSettings', () => { expect(settings.branding).toEqual(mockSignInExperience.branding); expect(settings.languageInfo).toEqual(mockSignInExperience.languageInfo); - expect(settings.termsOfUse).toEqual(mockSignInExperience.termsOfUse); + expect(settings.termsOfUseUrl).toEqual(mockSignInExperience.termsOfUseUrl); expect(settings.signUp.identifiers).toContain('username'); expect(settings.signIn.methods).toHaveLength(3); }); From 57a28be292c9d0532ab505e92a06836527f0490d Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Fri, 16 Dec 2022 09:59:10 +0800 Subject: [PATCH 3/7] fix(console): tip bubble position (#2674) --- .../src/components/ActionMenu/index.tsx | 2 +- .../console/src/components/Dropdown/index.tsx | 2 +- .../src/components/FormField/index.tsx | 2 +- .../Tip/TipBubble/index.module.scss | 52 +++++++++---------- .../src/components/Tip/TipBubble/index.tsx | 43 +++++++++++---- .../src/components/Tip/TipBubble/utils.ts | 22 ++++---- .../Tip/ToggleTip/index.module.scss | 17 +++--- .../src/components/Tip/ToggleTip/index.tsx | 32 +++++------- .../components/Tip/Tooltip/index.module.scss | 8 +-- .../src/components/Tip/Tooltip/index.tsx | 43 +++++++-------- packages/console/src/hooks/use-position.ts | 14 +---- .../components/GuideHeader/index.tsx | 2 +- packages/console/src/types/positioning.ts | 13 +++++ 13 files changed, 130 insertions(+), 122 deletions(-) create mode 100644 packages/console/src/types/positioning.ts diff --git a/packages/console/src/components/ActionMenu/index.tsx b/packages/console/src/components/ActionMenu/index.tsx index c8937af61..93903b262 100644 --- a/packages/console/src/components/ActionMenu/index.tsx +++ b/packages/console/src/components/ActionMenu/index.tsx @@ -2,7 +2,7 @@ import classNames from 'classnames'; import type { ReactNode } from 'react'; import { useRef, useState } from 'react'; -import type { HorizontalAlignment } from '@/hooks/use-position'; +import type { HorizontalAlignment } from '@/types/positioning'; import type { Props as ButtonProps } from '../Button'; import Dropdown from '../Dropdown'; diff --git a/packages/console/src/components/Dropdown/index.tsx b/packages/console/src/components/Dropdown/index.tsx index 84f8770a1..6c952167e 100644 --- a/packages/console/src/components/Dropdown/index.tsx +++ b/packages/console/src/components/Dropdown/index.tsx @@ -3,8 +3,8 @@ import type { ReactNode, RefObject } from 'react'; import { useRef } from 'react'; import ReactModal from 'react-modal'; -import type { HorizontalAlignment } from '@/hooks/use-position'; import usePosition from '@/hooks/use-position'; +import type { HorizontalAlignment } from '@/types/positioning'; import { onKeyDownHandler } from '@/utilities/a11y'; import * as styles from './index.module.scss'; diff --git a/packages/console/src/components/FormField/index.tsx b/packages/console/src/components/FormField/index.tsx index f672d7ad4..f101db946 100644 --- a/packages/console/src/components/FormField/index.tsx +++ b/packages/console/src/components/FormField/index.tsx @@ -29,7 +29,7 @@ const FormField = ({ title, children, isRequired, className, tip, headlineClassN
{typeof title === 'string' ? t(title) : title}
{tip && ( - + diff --git a/packages/console/src/components/Tip/TipBubble/index.module.scss b/packages/console/src/components/Tip/TipBubble/index.module.scss index 5f8ab299b..4f3de85fc 100644 --- a/packages/console/src/components/Tip/TipBubble/index.module.scss +++ b/packages/console/src/components/Tip/TipBubble/index.module.scss @@ -1,15 +1,19 @@ @use '@/scss/underscore' as _; .tipBubble { - position: relative; + position: absolute; border-radius: 8px; background: var(--color-tooltip-background); color: var(--color-tooltip-text); - box-shadow: var(--shadow-1); + box-shadow: var(--shadow-2); padding: _.unit(2) _.unit(3); font: var(--font-body-medium); max-width: 300px; + &.invisible { + opacity: 0%; + } + a { color: #cabeff; @@ -18,8 +22,7 @@ } } - &::after { - content: ''; + .arrow { display: block; position: absolute; width: 10px; @@ -29,34 +32,29 @@ transform: translate(-50%, -50%) rotate(45deg); } - &.top::after { - top: 100%; + &.top { + .arrow { + top: 100%; + } } - &.right::after { - top: 50%; - left: 0%; + &.right { + .arrow { + top: 50%; + left: 0%; + } } - &.bottom::after { - top: 0%; + &.bottom { + .arrow { + top: 0%; + } } - &.left::after { - top: 50%; - left: 100%; - } - - &.start::after { - left: _.unit(10); - } - - - &.center::after { - left: 50%; - } - - &.end::after { - right: _.unit(7.5); + &.left { + .arrow { + top: 50%; + left: 100%; + } } } diff --git a/packages/console/src/components/Tip/TipBubble/index.tsx b/packages/console/src/components/Tip/TipBubble/index.tsx index c2c2cd0af..2540527df 100644 --- a/packages/console/src/components/Tip/TipBubble/index.tsx +++ b/packages/console/src/components/Tip/TipBubble/index.tsx @@ -1,41 +1,64 @@ import { conditional } from '@silverhand/essentials'; import classNames from 'classnames'; import { forwardRef } from 'react'; -import type { ForwardedRef, ReactNode, HTMLProps } from 'react'; +import type { ForwardedRef, ReactNode, HTMLProps, RefObject } from 'react'; -import type { HorizontalAlignment } from '@/hooks/use-position'; +import type { HorizontalAlignment, Position } from '@/types/positioning'; import * as styles from './index.module.scss'; -export type TipBubblePosition = 'top' | 'right' | 'bottom' | 'left'; +export type TipBubblePlacement = 'top' | 'right' | 'bottom' | 'left'; type Props = HTMLProps & { children: ReactNode; - position?: TipBubblePosition; + position?: Position; + anchorRef: RefObject; + placement?: TipBubblePlacement; horizontalAlignment?: HorizontalAlignment; className?: string; }; -const supportHorizontalAlignmentPositions = new Set(['top', 'bottom']); +const supportHorizontalAlignmentPlacements = new Set(['top', 'bottom']); const TipBubble = ( - { children, position = 'bottom', horizontalAlignment = 'center', className, ...rest }: Props, + { + children, + position, + placement = 'bottom', + horizontalAlignment = 'center', + className, + anchorRef, + ...rest + }: Props, reference: ForwardedRef ) => { + if (!anchorRef.current) { + return null; + } + + const anchorRect = anchorRef.current.getBoundingClientRect(); + + const arrowPosition = conditional( + supportHorizontalAlignmentPlacements.has(placement) && + position && { + left: anchorRect.x + anchorRect.width / 2 - Number(position.left), + } + ); + return (
{children} +
); }; diff --git a/packages/console/src/components/Tip/TipBubble/utils.ts b/packages/console/src/components/Tip/TipBubble/utils.ts index fd416d9a2..9c0ad6aa4 100644 --- a/packages/console/src/components/Tip/TipBubble/utils.ts +++ b/packages/console/src/components/Tip/TipBubble/utils.ts @@ -1,9 +1,9 @@ -import type { HorizontalAlignment, VerticalAlignment } from '@/hooks/use-position'; +import type { HorizontalAlignment, VerticalAlignment } from '@/types/positioning'; -import type { TipBubblePosition } from '.'; +import type { TipBubblePlacement } from '.'; -export const getVerticalOffset = (position: TipBubblePosition) => { - switch (position) { +export const getVerticalOffset = (placement: TipBubblePlacement) => { + switch (placement) { case 'top': return -16; case 'bottom': @@ -14,10 +14,10 @@ export const getVerticalOffset = (position: TipBubblePosition) => { }; export const getHorizontalOffset = ( - tooltipPosition: TipBubblePosition, + placement: TipBubblePlacement, horizontalAlignment: HorizontalAlignment ): number => { - if (tooltipPosition === 'top' || tooltipPosition === 'bottom') { + if (placement === 'top' || placement === 'bottom') { switch (horizontalAlignment) { case 'start': return -32; @@ -27,12 +27,12 @@ export const getHorizontalOffset = ( return 0; } } else { - return tooltipPosition === 'left' ? -32 : 32; + return placement === 'left' ? -32 : 32; } }; -export const getVerticalAlignment = (position: TipBubblePosition): VerticalAlignment => { - switch (position) { +export const getVerticalAlignment = (placement: TipBubblePlacement): VerticalAlignment => { + switch (placement) { case 'top': return 'top'; case 'bottom': @@ -43,10 +43,10 @@ export const getVerticalAlignment = (position: TipBubblePosition): VerticalAlign }; export const getHorizontalAlignment = ( - position: TipBubblePosition, + placement: TipBubblePlacement, fallback: HorizontalAlignment ): HorizontalAlignment => { - switch (position) { + switch (placement) { case 'right': return 'start'; case 'left': diff --git a/packages/console/src/components/Tip/ToggleTip/index.module.scss b/packages/console/src/components/Tip/ToggleTip/index.module.scss index 0c0816adb..d9a91ab64 100644 --- a/packages/console/src/components/Tip/ToggleTip/index.module.scss +++ b/packages/console/src/components/Tip/ToggleTip/index.module.scss @@ -1,16 +1,15 @@ @use '@/scss/underscore' as _; -.content { - box-shadow: var(--shadow-2); - position: absolute; - - &:focus { - outline: none; - } -} - .overlay { background: transparent; position: fixed; inset: 0; + + .content { + position: relative; + + &:focus { + outline: none; + } + } } diff --git a/packages/console/src/components/Tip/ToggleTip/index.tsx b/packages/console/src/components/Tip/ToggleTip/index.tsx index da46eab14..42bce18df 100644 --- a/packages/console/src/components/Tip/ToggleTip/index.tsx +++ b/packages/console/src/components/Tip/ToggleTip/index.tsx @@ -2,11 +2,11 @@ import type { ReactNode } from 'react'; import { useCallback, useState, useRef } from 'react'; import ReactModal from 'react-modal'; -import type { HorizontalAlignment } from '@/hooks/use-position'; import usePosition from '@/hooks/use-position'; +import type { HorizontalAlignment } from '@/types/positioning'; import { onKeyDownHandler } from '@/utilities/a11y'; -import type { TipBubblePosition } from '../TipBubble'; +import type { TipBubblePlacement } from '../TipBubble'; import TipBubble from '../TipBubble'; import { getVerticalAlignment, @@ -20,7 +20,7 @@ export type Props = { children: ReactNode; className?: string; anchorClassName?: string; - position?: TipBubblePosition; + placement?: TipBubblePlacement; horizontalAlign?: HorizontalAlignment; content?: ((closeTip: () => void) => ReactNode) | ReactNode; }; @@ -29,11 +29,11 @@ const ToggleTip = ({ children, className, anchorClassName, - position = 'top', + placement = 'top', horizontalAlign = 'center', content, }: Props) => { - const overlayRef = useRef(null); + const tipBubbleRef = useRef(null); const anchorRef = useRef(null); const [isOpen, setIsOpen] = useState(false); @@ -47,14 +47,14 @@ const ToggleTip = ({ positionState, mutate, } = usePosition({ - verticalAlign: getVerticalAlignment(position), - horizontalAlign: getHorizontalAlignment(position, horizontalAlign), + verticalAlign: getVerticalAlignment(placement), + horizontalAlign: getHorizontalAlignment(placement, horizontalAlign), offset: { - vertical: getVerticalOffset(position), - horizontal: getHorizontalOffset(position, horizontalAlign), + vertical: getVerticalOffset(placement), + horizontal: getHorizontalOffset(placement, horizontalAlign), }, anchorRef, - overlayRef, + overlayRef: tipBubbleRef, }); return ( @@ -77,20 +77,16 @@ const ToggleTip = ({ shouldCloseOnOverlayClick shouldCloseOnEsc isOpen={isOpen} - style={{ - content: { - ...(!layoutPosition && { opacity: 0 }), - ...layoutPosition, - }, - }} className={styles.content} overlayClassName={styles.overlay} onRequestClose={onClose} onAfterOpen={mutate} > diff --git a/packages/console/src/components/Tip/Tooltip/index.module.scss b/packages/console/src/components/Tip/Tooltip/index.module.scss index bc2af8bca..2e1ddb9ff 100644 --- a/packages/console/src/components/Tip/Tooltip/index.module.scss +++ b/packages/console/src/components/Tip/Tooltip/index.module.scss @@ -1,9 +1,5 @@ @use '@/scss/underscore' as _; -.tooltip { - position: absolute; - - .content { - @include _.multi-line-ellipsis(6); - } +.content { + @include _.multi-line-ellipsis(6); } diff --git a/packages/console/src/components/Tip/Tooltip/index.tsx b/packages/console/src/components/Tip/Tooltip/index.tsx index c9aa037dc..4d6be04c5 100644 --- a/packages/console/src/components/Tip/Tooltip/index.tsx +++ b/packages/console/src/components/Tip/Tooltip/index.tsx @@ -2,11 +2,11 @@ import type { ReactNode } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import type { HorizontalAlignment } from '@/hooks/use-position'; import usePosition from '@/hooks/use-position'; +import type { HorizontalAlignment } from '@/types/positioning'; import TipBubble from '../TipBubble'; -import type { TipBubblePosition } from '../TipBubble'; +import type { TipBubblePlacement } from '../TipBubble'; import { getVerticalAlignment, getHorizontalAlignment, @@ -18,7 +18,7 @@ import * as styles from './index.module.scss'; type Props = { className?: string; isKeepOpen?: boolean; - position?: TipBubblePosition; + placement?: TipBubblePlacement; horizontalAlign?: HorizontalAlignment; anchorClassName?: string; children?: ReactNode; @@ -28,7 +28,7 @@ type Props = { const Tooltip = ({ className, isKeepOpen = false, - position = 'top', + placement = 'top', horizontalAlign = 'center', anchorClassName, children, @@ -38,16 +38,12 @@ const Tooltip = ({ const anchorRef = useRef(null); const tooltipRef = useRef(null); - const { - position: layoutPosition, - positionState, - mutate, - } = usePosition({ - verticalAlign: getVerticalAlignment(position), - horizontalAlign: getHorizontalAlignment(position, horizontalAlign), + const { position, positionState, mutate } = usePosition({ + verticalAlign: getVerticalAlignment(placement), + horizontalAlign: getHorizontalAlignment(placement, horizontalAlign), offset: { - vertical: getVerticalOffset(position), - horizontal: getHorizontalOffset(position, horizontalAlign), + vertical: getVerticalOffset(placement), + horizontal: getHorizontalOffset(placement, horizontalAlign), }, anchorRef, overlayRef: tooltipRef, @@ -132,17 +128,16 @@ const Tooltip = ({ {tooltipDom && content && createPortal( -
- -
{content}
-
-
, + +
{content}
+
, tooltipDom )} diff --git a/packages/console/src/hooks/use-position.ts b/packages/console/src/hooks/use-position.ts index d7d5d0885..a4910aea9 100644 --- a/packages/console/src/hooks/use-position.ts +++ b/packages/console/src/hooks/use-position.ts @@ -1,14 +1,7 @@ import type { RefObject } from 'react'; import { useCallback, useEffect, useState } from 'react'; -export type VerticalAlignment = 'top' | 'middle' | 'bottom'; - -export type HorizontalAlignment = 'start' | 'center' | 'end'; - -type Offset = { - vertical: number; - horizontal: number; -}; +import type { HorizontalAlignment, Offset, Position, VerticalAlignment } from '@/types/positioning'; type Props = { verticalAlign: VerticalAlignment; @@ -18,11 +11,6 @@ type Props = { overlayRef: RefObject; }; -type Position = { - top: number; - left: number; -}; - // Leave space for box-shadow effect. const windowSafePadding = 12; diff --git a/packages/console/src/pages/Applications/components/GuideHeader/index.tsx b/packages/console/src/pages/Applications/components/GuideHeader/index.tsx index 3ec1a9b32..f59163cd3 100644 --- a/packages/console/src/pages/Applications/components/GuideHeader/index.tsx +++ b/packages/console/src/pages/Applications/components/GuideHeader/index.tsx @@ -63,7 +63,7 @@ const GuideHeader = ({ appName, selectedSdk, isCompact = false, onClose }: Props /> diff --git a/packages/console/src/types/positioning.ts b/packages/console/src/types/positioning.ts new file mode 100644 index 000000000..a56b8d52c --- /dev/null +++ b/packages/console/src/types/positioning.ts @@ -0,0 +1,13 @@ +export type Position = { + top: number; + left: number; +}; + +export type Offset = { + horizontal: number; + vertical: number; +}; + +export type HorizontalAlignment = 'start' | 'center' | 'end'; + +export type VerticalAlignment = 'top' | 'middle' | 'bottom'; From 61f00449daeab1f9aa1e04415a43ed832e413c4e Mon Sep 17 00:00:00 2001 From: simeng-li Date: Fri, 16 Dec 2022 10:17:32 +0800 Subject: [PATCH 4/7] feat(test): add username, phone, email register integration test (#2671) --- .../register-with-identifier.test.ts | 280 ++++++++++++++++++ .../sign-in-with-passcode-identifier.test.ts | 36 ++- .../sign-in-with-password-identifier.test.ts | 44 +-- .../src/tests/interaction/utils/client.ts | 8 +- .../src/tests/interaction/utils/user.ts | 18 +- 5 files changed, 333 insertions(+), 53 deletions(-) create mode 100644 packages/integration-tests/src/tests/interaction/register-with-identifier.test.ts diff --git a/packages/integration-tests/src/tests/interaction/register-with-identifier.test.ts b/packages/integration-tests/src/tests/interaction/register-with-identifier.test.ts new file mode 100644 index 000000000..e6484b79c --- /dev/null +++ b/packages/integration-tests/src/tests/interaction/register-with-identifier.test.ts @@ -0,0 +1,280 @@ +import { ConnectorType, Event, SignInIdentifier } from '@logto/schemas'; +import { assert } from '@silverhand/essentials'; + +import { + sendVerificationPasscode, + putInteraction, + patchInteraction, + deleteUser, +} from '#src/api/index.js'; +import { readPasscode, expectRejects } from '#src/helpers.js'; + +import { initClient, processSession, logoutClient } from './utils/client.js'; +import { clearConnectorsByTypes, setEmailConnector, setSmsConnector } from './utils/connector.js'; +import { + enableAllPasscodeSignInMethods, + enableAllPasswordSignInMethods, +} from './utils/sign-in-experience.js'; +import { generateNewUserProfile, generateNewUser } from './utils/user.js'; + +describe('Register with username and password', () => { + it('register with username and password', async () => { + await enableAllPasswordSignInMethods({ + identifiers: [SignInIdentifier.Username], + password: true, + verify: false, + }); + + const { username, password } = generateNewUserProfile({ username: true, password: true }); + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + const { redirectTo } = await putInteraction( + { + event: Event.Register, + profile: { + username, + password, + }, + }, + client.interactionCookie + ); + + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); + }); +}); + +describe('Register with passwordless identifier', () => { + beforeAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + await setEmailConnector(); + await setSmsConnector(); + }); + afterAll(async () => { + await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]); + }); + + it('register with email', async () => { + await enableAllPasscodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + + const { primaryEmail } = generateNewUserProfile({ primaryEmail: true }); + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.Register, + email: primaryEmail, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + expect(passcodeRecord).toMatchObject({ + address: primaryEmail, + type: Event.Register, + }); + + const { code } = passcodeRecord; + + const { redirectTo } = await putInteraction( + { + event: Event.Register, + identifier: { + email: primaryEmail, + passcode: code, + }, + profile: { + email: primaryEmail, + }, + }, + client.interactionCookie + ); + + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); + }); + + it('register with phone', async () => { + await enableAllPasscodeSignInMethods({ + identifiers: [SignInIdentifier.Sms], + password: false, + verify: true, + }); + + const { primaryPhone } = generateNewUserProfile({ primaryPhone: true }); + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.Register, + phone: primaryPhone, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + expect(passcodeRecord).toMatchObject({ + phone: primaryPhone, + type: Event.Register, + }); + + const { code } = passcodeRecord; + + const { redirectTo } = await putInteraction( + { + event: Event.Register, + identifier: { + phone: primaryPhone, + passcode: code, + }, + profile: { + phone: primaryPhone, + }, + }, + client.interactionCookie + ); + + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); + }); + + it('register with exiting email', async () => { + const { + user, + userProfile: { primaryEmail }, + } = await generateNewUser({ primaryEmail: true }); + + await enableAllPasscodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.Register, + email: primaryEmail, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + expect(passcodeRecord).toMatchObject({ + address: primaryEmail, + type: Event.Register, + }); + + const { code } = passcodeRecord; + + await expectRejects( + putInteraction( + { + event: Event.Register, + identifier: { + email: primaryEmail, + passcode: code, + }, + profile: { + email: primaryEmail, + }, + }, + client.interactionCookie + ), + 'user.email_already_in_use' + ); + + const { redirectTo } = await patchInteraction( + { + event: Event.SignIn, + }, + client.interactionCookie + ); + await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(user.id); + }); + + it('register with exiting phone', async () => { + const { + user, + userProfile: { primaryPhone }, + } = await generateNewUser({ primaryPhone: true }); + + await enableAllPasscodeSignInMethods({ + identifiers: [SignInIdentifier.Sms], + password: false, + verify: true, + }); + + const client = await initClient(); + assert(client.interactionCookie, new Error('Session not found')); + + await expect( + sendVerificationPasscode( + { + event: Event.Register, + phone: primaryPhone, + }, + client.interactionCookie + ) + ).resolves.not.toThrow(); + + const passcodeRecord = await readPasscode(); + + expect(passcodeRecord).toMatchObject({ + phone: primaryPhone, + type: Event.Register, + }); + + const { code } = passcodeRecord; + + await expectRejects( + putInteraction( + { + event: Event.Register, + identifier: { + phone: primaryPhone, + passcode: code, + }, + profile: { + phone: primaryPhone, + }, + }, + client.interactionCookie + ), + 'user.phone_already_in_use' + ); + + const { redirectTo } = await patchInteraction( + { + event: Event.SignIn, + }, + client.interactionCookie + ); + await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(user.id); + }); +}); diff --git a/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts b/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts index 743674a96..228dc898c 100644 --- a/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts +++ b/packages/integration-tests/src/tests/interaction/sign-in-with-passcode-identifier.test.ts @@ -8,10 +8,10 @@ import { deleteUser, updateSignInExperience, } from '#src/api/index.js'; -import { readPasscode } from '#src/helpers.js'; +import { expectRejects, readPasscode } from '#src/helpers.js'; import { generateEmail, generatePhone } from '#src/utils.js'; -import { initClient, processSessionAndLogout } from './utils/client.js'; +import { initClient, processSession, logoutClient } from './utils/client.js'; import { clearConnectorsByTypes, setEmailConnector, setSmsConnector } from './utils/connector.js'; import { enableAllPasscodeSignInMethods } from './utils/sign-in-experience.js'; import { generateNewUser } from './utils/user.js'; @@ -62,8 +62,8 @@ describe('Sign-In flow using passcode identifiers', () => { client.interactionCookie ); - await processSessionAndLogout(client, redirectTo); - + await processSession(client, redirectTo); + await logoutClient(client); await deleteUser(user.id); }); @@ -102,8 +102,8 @@ describe('Sign-In flow using passcode identifiers', () => { client.interactionCookie ); - await processSessionAndLogout(client, redirectTo); - + await processSession(client, redirectTo); + await logoutClient(client); await deleteUser(user.id); }); @@ -132,8 +132,7 @@ describe('Sign-In flow using passcode identifiers', () => { const { code } = passcodeRecord; - // TODO: @simeng use expectRequestError after https://github.com/logto-io/logto/pull/2639/ PR merged - await expect( + await expectRejects( putInteraction( { event: Event.SignIn, @@ -143,8 +142,9 @@ describe('Sign-In flow using passcode identifiers', () => { }, }, client.interactionCookie - ) - ).rejects.toThrow(); + ), + 'user.user_not_exist' + ); const { redirectTo } = await patchInteraction( { @@ -156,7 +156,9 @@ describe('Sign-In flow using passcode identifiers', () => { client.interactionCookie ); - await processSessionAndLogout(client, redirectTo); + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); }); it('sign-in with non-exist phone account with passcode', async () => { @@ -184,8 +186,7 @@ describe('Sign-In flow using passcode identifiers', () => { const { code } = passcodeRecord; - // TODO: @simeng use expectRequestError after https://github.com/logto-io/logto/pull/2639/ PR merged - await expect( + await expectRejects( putInteraction( { event: Event.SignIn, @@ -195,8 +196,9 @@ describe('Sign-In flow using passcode identifiers', () => { }, }, client.interactionCookie - ) - ).rejects.toThrow(); + ), + 'user.user_not_exist' + ); const { redirectTo } = await patchInteraction( { @@ -208,6 +210,8 @@ describe('Sign-In flow using passcode identifiers', () => { client.interactionCookie ); - await processSessionAndLogout(client, redirectTo); + const id = await processSession(client, redirectTo); + await logoutClient(client); + await deleteUser(id); }); }); diff --git a/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts b/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts index f01da415f..0475f20c4 100644 --- a/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts +++ b/packages/integration-tests/src/tests/interaction/sign-in-with-password-identifier.test.ts @@ -2,8 +2,8 @@ import { Event } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { putInteraction, deleteUser } from '#src/api/index.js'; -import MockClient from '#src/client/index.js'; +import { initClient, processSession, logoutClient } from './utils/client.js'; import { enableAllPasswordSignInMethods } from './utils/sign-in-experience.js'; import { generateNewUser } from './utils/user.js'; @@ -13,9 +13,8 @@ describe('Sign-In flow using password identifiers', () => { }); it('sign-in with username and password', async () => { - const { userProfile, user } = await generateNewUser({ username: true }); - const client = new MockClient(); - await client.initSession(); + const { userProfile, user } = await generateNewUser({ username: true, password: true }); + const client = await initClient(); assert(client.interactionCookie, new Error('Session not found')); const { redirectTo } = await putInteraction( @@ -29,21 +28,15 @@ describe('Sign-In flow using password identifiers', () => { client.interactionCookie ); - await client.processSession(redirectTo); - - await expect(client.isAuthenticated()).resolves.toBe(true); - - await client.signOut(); - - await expect(client.isAuthenticated()).resolves.toBe(false); + await processSession(client, redirectTo); + await logoutClient(client); await deleteUser(user.id); }); it('sign-in with email and password', async () => { - const { userProfile, user } = await generateNewUser({ primaryEmail: true }); - const client = new MockClient(); - await client.initSession(); + const { userProfile, user } = await generateNewUser({ primaryEmail: true, password: true }); + const client = await initClient(); assert(client.interactionCookie, new Error('Session not found')); const { redirectTo } = await putInteraction( @@ -57,21 +50,15 @@ describe('Sign-In flow using password identifiers', () => { client.interactionCookie ); - await client.processSession(redirectTo); - - await expect(client.isAuthenticated()).resolves.toBe(true); - - await client.signOut(); - - await expect(client.isAuthenticated()).resolves.toBe(false); + await processSession(client, redirectTo); + await logoutClient(client); await deleteUser(user.id); }); it('sign-in with phone and password', async () => { - const { userProfile, user } = await generateNewUser({ primaryPhone: true }); - const client = new MockClient(); - await client.initSession(); + const { userProfile, user } = await generateNewUser({ primaryPhone: true, password: true }); + const client = await initClient(); assert(client.interactionCookie, new Error('Session not found')); const { redirectTo } = await putInteraction( @@ -85,13 +72,8 @@ describe('Sign-In flow using password identifiers', () => { client.interactionCookie ); - await client.processSession(redirectTo); - - await expect(client.isAuthenticated()).resolves.toBe(true); - - await client.signOut(); - - await expect(client.isAuthenticated()).resolves.toBe(false); + await processSession(client, redirectTo); + await logoutClient(client); await deleteUser(user.id); }); diff --git a/packages/integration-tests/src/tests/interaction/utils/client.ts b/packages/integration-tests/src/tests/interaction/utils/client.ts index 19c24261f..bbeaca0e6 100644 --- a/packages/integration-tests/src/tests/interaction/utils/client.ts +++ b/packages/integration-tests/src/tests/interaction/utils/client.ts @@ -7,11 +7,17 @@ export const initClient = async () => { return client; }; -export const processSessionAndLogout = async (client: MockClient, redirectTo: string) => { +export const processSession = async (client: MockClient, redirectTo: string) => { await client.processSession(redirectTo); await expect(client.isAuthenticated()).resolves.toBe(true); + const { sub } = await client.getIdTokenClaims(); + + return sub; +}; + +export const logoutClient = async (client: MockClient) => { await client.signOut(); await expect(client.isAuthenticated()).resolves.toBe(false); diff --git a/packages/integration-tests/src/tests/interaction/utils/user.ts b/packages/integration-tests/src/tests/interaction/utils/user.ts index 92f3b4d96..2705f58d9 100644 --- a/packages/integration-tests/src/tests/interaction/utils/user.ts +++ b/packages/integration-tests/src/tests/interaction/utils/user.ts @@ -9,31 +9,39 @@ import { export type NewUserProfileOptions = { username?: true; + password?: true; + name?: true; primaryEmail?: true; primaryPhone?: true; }; -export const generateNewUser = async ({ +export const generateNewUserProfile = ({ username, + password, + name, primaryEmail, primaryPhone, }: T) => { type UserProfile = { - password: string; - name: string; - } & { [K in keyof T]: T[K] extends true ? string : never; }; // @ts-expect-error - TS can't map the type of userProfile to the UserProfile defined above const userProfile: UserProfile = { - password: generatePassword(), name: generateName(), ...(username ? { username: generateUsername() } : {}), + ...(password ? { password: generatePassword() } : {}), + ...(name ? { name: generateName() } : {}), ...(primaryEmail ? { primaryEmail: generateEmail() } : {}), ...(primaryPhone ? { primaryPhone: generatePhone() } : {}), }; + return userProfile; +}; + +export const generateNewUser = async (options: T) => { + const userProfile = generateNewUserProfile(options); + const user = await createUser(userProfile); return { user, userProfile }; From 9ef395f6684c0869e9f4372df69ae86e1ac16c8b Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Fri, 16 Dec 2022 22:09:38 +0800 Subject: [PATCH 5/7] refactor(ui): add support for fullscreen profile layout (#2622) --- packages/ui/src/App.tsx | 96 ++++++++++--------- .../containers/AppBoundary/index.module.scss | 22 +++++ .../ui/src/containers/AppBoundary/index.tsx | 48 ++++++++++ .../containers/AppContent/index.module.scss | 38 -------- .../ui/src/containers/AppContent/index.tsx | 53 +++------- packages/ui/src/pages/Profile/index.tsx | 5 + packages/ui/src/scss/normalized.scss | 13 ++- 7 files changed, 150 insertions(+), 125 deletions(-) create mode 100644 packages/ui/src/containers/AppBoundary/index.module.scss create mode 100644 packages/ui/src/containers/AppBoundary/index.tsx create mode 100644 packages/ui/src/pages/Profile/index.tsx diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index bea4d67bf..5a5215e04 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -2,6 +2,7 @@ import { SignInMode } from '@logto/schemas'; import { useEffect } from 'react'; import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom'; +import AppBoundary from './containers/AppBoundary'; import AppContent from './containers/AppContent'; import LoadingLayerProvider from './containers/LoadingLayerProvider'; import usePageContext from './hooks/use-page-context'; @@ -15,6 +16,7 @@ import ErrorPage from './pages/ErrorPage'; import ForgotPassword from './pages/ForgotPassword'; import Passcode from './pages/Passcode'; import PasswordRegisterWithUsername from './pages/PasswordRegisterWithUsername'; +import Profile from './pages/Profile'; import Register from './pages/Register'; import ResetPassword from './pages/ResetPassword'; import SecondaryRegister from './pages/SecondaryRegister'; @@ -57,60 +59,66 @@ const App = () => { const isSignInOnly = experienceSettings.signInMode === SignInMode.SignIn; return ( - - - + + + - } /> - } /> - } - /> - - }> - {/* Sign-in */} + } /> + }> + } /> + } /> : } + path="/unknown-session" + element={} /> - } /> - } /> - } /> - {/* Register */} - : } - /> - } - /> - } /> + }> + {/* Sign-in */} + : } + /> + } /> + } /> + } /> - {/* Forgot password */} - } /> - } /> + {/* Register */} + : } + /> + } + /> + } /> - {/* Continue set up missing profile */} - } /> - } /> + {/* Forgot password */} + } /> + } /> - {/* Social sign-in pages */} - } /> - } /> - } /> + {/* Continue set up missing profile */} + } + /> + } /> - {/* Always keep route path with param as the last one */} - } /> + {/* Social sign-in pages */} + } /> + } /> + } /> + + {/* Always keep route path with param as the last one */} + } /> + + + } /> - - } /> - - - + + + ); }; diff --git a/packages/ui/src/containers/AppBoundary/index.module.scss b/packages/ui/src/containers/AppBoundary/index.module.scss new file mode 100644 index 000000000..62b82ec69 --- /dev/null +++ b/packages/ui/src/containers/AppBoundary/index.module.scss @@ -0,0 +1,22 @@ +@use '@/scss/colors' as colors; +@use '@/scss/underscore' as _; + +body { + &.light { + @include colors.light; + } + + &.dark { + @include colors.dark; + } +} + +:global(body.mobile) { + --max-width: 360px; + background: var(--color-bg-body); +} + +:global(body.desktop) { + --max-width: 400px; + background: var(--color-bg-float-base); +} diff --git a/packages/ui/src/containers/AppBoundary/index.tsx b/packages/ui/src/containers/AppBoundary/index.tsx new file mode 100644 index 000000000..36ef98761 --- /dev/null +++ b/packages/ui/src/containers/AppBoundary/index.tsx @@ -0,0 +1,48 @@ +import { conditionalString } from '@silverhand/essentials'; +import type { ReactNode } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; + +import Toast from '@/components/Toast'; +import useColorTheme from '@/hooks/use-color-theme'; +import { PageContext } from '@/hooks/use-page-context'; +import useTheme from '@/hooks/use-theme'; + +import ConfirmModalProvider from '../ConfirmModalProvider'; +import * as styles from './index.module.scss'; + +type Props = { + children: ReactNode; +}; + +const AppBoundary = ({ children }: Props) => { + // Set Primary Color + useColorTheme(); + const theme = useTheme(); + const { platform, toast, setToast } = useContext(PageContext); + + // Set Theme Mode + useEffect(() => { + document.body.classList.remove(conditionalString(styles.light), conditionalString(styles.dark)); + document.body.classList.add(conditionalString(styles[theme])); + }, [theme]); + + // Apply Platform Style + useEffect(() => { + document.body.classList.remove('desktop', 'mobile'); + document.body.classList.add(platform === 'mobile' ? 'mobile' : 'desktop'); + }, [platform]); + + // Prevent internal eventListener rebind + const hideToast = useCallback(() => { + setToast(''); + }, [setToast]); + + return ( + + + {children} + + ); +}; + +export default AppBoundary; diff --git a/packages/ui/src/containers/AppContent/index.module.scss b/packages/ui/src/containers/AppContent/index.module.scss index a530f8f1d..084d4ad84 100644 --- a/packages/ui/src/containers/AppContent/index.module.scss +++ b/packages/ui/src/containers/AppContent/index.module.scss @@ -1,6 +1,4 @@ @use '@/scss/underscore' as _; -@use '@/scss/colors' as colors; -@use '@/scss/fonts' as fonts; /* Preview Settings */ .preview { @@ -13,30 +11,10 @@ } } -/* Foundation */ -body { - --radius: 8px; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: auto; - word-break: break-word; - @include colors.static; - - &.light { - @include colors.light; - } - - &.dark { - @include colors.dark; - } - - @include fonts.fonts; -} - /* Main Layout */ .container { position: absolute; inset: 0; - color: var(--color-type-primary); overflow: auto; @include _.flex_column(center, normal); } @@ -51,14 +29,6 @@ body { } :global(body.mobile) { - --max-width: 360px; - background: var(--color-bg-body); - - .container { - background: var(--color-bg-body); - font: var(--font-body-2); - } - .main { flex: 1; align-self: stretch; @@ -69,14 +39,6 @@ body { } :global(body.desktop) { - --max-width: 400px; - background: var(--color-bg-float-base); - - .container { - background: var(--color-bg-float-base); - font: var(--font-body-2); - } - .main { width: 640px; min-height: 640px; diff --git a/packages/ui/src/containers/AppContent/index.tsx b/packages/ui/src/containers/AppContent/index.tsx index 2683d2733..04104a9ac 100644 --- a/packages/ui/src/containers/AppContent/index.tsx +++ b/packages/ui/src/containers/AppContent/index.tsx @@ -1,52 +1,21 @@ -import { conditionalString } from '@silverhand/essentials'; -import type { ReactNode } from 'react'; -import { useEffect, useCallback, useContext } from 'react'; +import { useContext } from 'react'; +import { Outlet } from 'react-router-dom'; -import Toast from '@/components/Toast'; -import ConfirmModalProvider from '@/containers/ConfirmModalProvider'; -import useColorTheme from '@/hooks/use-color-theme'; import { PageContext } from '@/hooks/use-page-context'; -import useTheme from '@/hooks/use-theme'; import * as styles from './index.module.scss'; -export type Props = { - children: ReactNode; -}; - -const AppContent = ({ children }: Props) => { - const theme = useTheme(); - const { toast, platform, setToast } = useContext(PageContext); - - // Prevent internal eventListener rebind - const hideToast = useCallback(() => { - setToast(''); - }, [setToast]); - - // Set Primary Color - useColorTheme(); - - // Set Theme Mode - useEffect(() => { - document.body.classList.remove(conditionalString(styles.light), conditionalString(styles.dark)); - document.body.classList.add(conditionalString(styles[theme])); - }, [theme]); - - // Apply Platform Style - useEffect(() => { - document.body.classList.remove('desktop', 'mobile'); - document.body.classList.add(platform === 'mobile' ? 'mobile' : 'desktop'); - }, [platform]); +const AppContent = () => { + const { platform } = useContext(PageContext); return ( - -
- {platform === 'web' &&
} -
{children}
- {platform === 'web' &&
} - -
- +
+ {platform === 'web' &&
} +
+ +
+ {platform === 'web' &&
} +
); }; diff --git a/packages/ui/src/pages/Profile/index.tsx b/packages/ui/src/pages/Profile/index.tsx new file mode 100644 index 000000000..b023e60ab --- /dev/null +++ b/packages/ui/src/pages/Profile/index.tsx @@ -0,0 +1,5 @@ +const Profile = () => { + return <>Profile works!; +}; + +export default Profile; diff --git a/packages/ui/src/scss/normalized.scss b/packages/ui/src/scss/normalized.scss index a71811085..c9ff891bf 100644 --- a/packages/ui/src/scss/normalized.scss +++ b/packages/ui/src/scss/normalized.scss @@ -1,7 +1,18 @@ +@use '@/scss/colors' as colors; +@use '@/scss/fonts' as fonts; + body { + @include colors.static; + @include fonts.fonts; + + --radius: 8px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: auto; margin: 0; padding: 0; - font-family: sans-serif; + word-break: break-word; + color: var(--color-type-primary); + font: var(--font-body-2); } * { From 858e1190e6655c99375f325fcc786c24ff6327a3 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Fri, 16 Dec 2022 22:20:47 +0800 Subject: [PATCH 6/7] feat(ui): add desktop profile page (#2624) --- packages/phrases-ui/src/locales/de.ts | 27 +++++++++ packages/phrases-ui/src/locales/en.ts | 27 +++++++++ packages/phrases-ui/src/locales/fr.ts | 27 +++++++++ packages/phrases-ui/src/locales/ko.ts | 27 +++++++++ packages/phrases-ui/src/locales/pt-br.ts | 27 +++++++++ packages/phrases-ui/src/locales/pt-pt.ts | 27 +++++++++ packages/phrases-ui/src/locales/tr-tr.ts | 27 +++++++++ packages/phrases-ui/src/locales/zh-cn.ts | 27 +++++++++ packages/ui/src/apis/profile.ts | 8 +++ .../components/FormCard/index.module.scss | 31 ++++++++++ .../Profile/components/FormCard/index.tsx | 23 +++++++ .../components/Table/index.module.scss | 30 ++++++++++ .../pages/Profile/components/Table/index.tsx | 44 ++++++++++++++ .../ui/src/pages/Profile/index.module.scss | 42 +++++++++++++ packages/ui/src/pages/Profile/index.tsx | 60 ++++++++++++++++++- packages/ui/src/scss/_colors.scss | 7 +++ packages/ui/src/scss/_fonts.scss | 2 + 17 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/apis/profile.ts create mode 100644 packages/ui/src/pages/Profile/components/FormCard/index.module.scss create mode 100644 packages/ui/src/pages/Profile/components/FormCard/index.tsx create mode 100644 packages/ui/src/pages/Profile/components/Table/index.module.scss create mode 100644 packages/ui/src/pages/Profile/components/Table/index.tsx create mode 100644 packages/ui/src/pages/Profile/index.module.scss diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index b393fd794..d7de6b42c 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -85,6 +85,33 @@ const translation = { 'For added security, please link your email or phone with the account.', // UNTRANSLATED continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: 'Benutzername oder Passwort ist falsch', username_required: 'Benutzername ist erforderlich', diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index 9b20e56f2..ba13b7d40 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -81,6 +81,33 @@ const translation = { 'For added security, please link your email or phone with the account.', continue_with_more_information: 'For added security, please complete below account details.', }, + profile: { + title: 'Account Settings', + description: + 'Change your account settings and manage your personal information here to ensure your account security.', + settings: { + title: 'PROFILE SETTINGS', + profile_information: 'Profile Information', + avatar: 'Avatar', + name: 'Name', + username: 'Username', + }, + password: { + title: 'PASSWORD', + reset_password: 'Reset Password', + }, + link_account: { + title: 'LINK ACCOUNT', + email_phone_sign_in: 'Email / Phone Sign-In', + email: 'Email', + phone: 'Phone', + social_sign_in: 'Social Sign-In', + }, + edit: 'Edit', + change: 'Change', + link: 'Link', + unlink: 'Unlink', + }, error: { username_password_mismatch: 'Username and password do not match', username_required: 'Username is required', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index 1ed001abc..fd8d04ebb 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -85,6 +85,33 @@ const translation = { 'For added security, please link your email or phone with the account.', // UNTRANSLATED continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas", username_required: "Le nom d'utilisateur est requis", diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index 91ae10194..3fcb8e982 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -81,6 +81,33 @@ const translation = { 'For added security, please link your email or phone with the account.', // UNTRANSLATED continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.', username_required: '사용자 이름은 필수예요.', diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts index 46f1df7e7..2bd3973ea 100644 --- a/packages/phrases-ui/src/locales/pt-br.ts +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -83,6 +83,33 @@ const translation = { 'Para maior segurança, vincule seu e-mail ou telefone à conta.', continue_with_more_information: 'Para maior segurança, preencha os detalhes da conta abaixo.', }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: 'Usuário e senha não correspondem', username_required: 'Nome de usuário é obrigatório', diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index 361453d8a..be7c2c9a7 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -81,6 +81,33 @@ const translation = { 'For added security, please link your email or phone with the account.', // UNTRANSLATED continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: 'O Utilizador e a password não correspondem', username_required: 'Utilizador necessário', diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 24cbd41ee..7def7cc39 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -82,6 +82,33 @@ const translation = { 'For added security, please link your email or phone with the account.', // UNTRANSLATED continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.', username_required: 'Kullanıcı adı gerekli.', diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index 281b82d8d..2e2855bc2 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -77,6 +77,33 @@ const translation = { link_email_or_phone_description: '绑定邮箱或手机号以保障您的账号安全', continue_with_more_information: '为保障您的账号安全,需要您补充以下信息。', }, + profile: { + title: 'Account Settings', // UNTRANSLATED + description: + 'Change your account settings and manage your personal information here to ensure your account security.', // UNTRANSLATED + settings: { + title: 'PROFILE SETTINGS', // UNTRANSLATED + profile_information: 'Profile Information', // UNTRANSLATED + avatar: 'Avatar', // UNTRANSLATED + name: 'Name', // UNTRANSLATED + username: 'Username', // UNTRANSLATED + }, + password: { + title: 'PASSWORD', // UNTRANSLATED + reset_password: 'Reset Password', // UNTRANSLATED + }, + link_account: { + title: 'LINK ACCOUNT', // UNTRANSLATED + email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED + email: 'Email', // UNTRANSLATED + phone: 'Phone', // UNTRANSLATED + social_sign_in: 'Social Sign-In', // UNTRANSLATED + }, + edit: 'Edit', // UNTRANSLATED + change: 'Change', // UNTRANSLATED + link: 'Link', // UNTRANSLATED + unlink: 'Unlink', // UNTRANSLATED + }, error: { username_password_mismatch: '用户名和密码不匹配', username_required: '用户名必填', diff --git a/packages/ui/src/apis/profile.ts b/packages/ui/src/apis/profile.ts new file mode 100644 index 000000000..5736ec452 --- /dev/null +++ b/packages/ui/src/apis/profile.ts @@ -0,0 +1,8 @@ +import type { UserInfo } from '@logto/schemas'; + +import api from './api'; + +const profileApiPrefix = '/api/profile'; + +export const getUserProfile = async (): Promise => + api.get(profileApiPrefix).json(); diff --git a/packages/ui/src/pages/Profile/components/FormCard/index.module.scss b/packages/ui/src/pages/Profile/components/FormCard/index.module.scss new file mode 100644 index 000000000..95e14be5d --- /dev/null +++ b/packages/ui/src/pages/Profile/components/FormCard/index.module.scss @@ -0,0 +1,31 @@ +@use '@/scss/underscore' as _; + +.container { + padding: _.unit(6) _.unit(8); + display: flex; + margin-top: _.unit(4); + background: var(--color-bg-layer-1); + border-radius: 12px; +} + +.title { + width: 405px; + flex-shrink: 0; + color: var(--color-neutral-variant-60); + font: var(--font-subhead-cap); +} + +.content { + flex-grow: 1; +} + +@media screen and (max-width: 1080px) { + .container { + flex-direction: column; + + .content { + margin-top: _.unit(4); + flex-grow: unset; + } + } +} diff --git a/packages/ui/src/pages/Profile/components/FormCard/index.tsx b/packages/ui/src/pages/Profile/components/FormCard/index.tsx new file mode 100644 index 000000000..470059348 --- /dev/null +++ b/packages/ui/src/pages/Profile/components/FormCard/index.tsx @@ -0,0 +1,23 @@ +import type { I18nKey } from '@logto/phrases-ui'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './index.module.scss'; + +type Props = { + title: I18nKey; + children: ReactNode; +}; + +const FormCard = ({ title, children }: Props) => { + const { t } = useTranslation(); + + return ( +
+
{t(title)}
+
{children}
+
+ ); +}; + +export default FormCard; diff --git a/packages/ui/src/pages/Profile/components/Table/index.module.scss b/packages/ui/src/pages/Profile/components/Table/index.module.scss new file mode 100644 index 000000000..b13c91a9c --- /dev/null +++ b/packages/ui/src/pages/Profile/components/Table/index.module.scss @@ -0,0 +1,30 @@ +@use '@/scss/underscore' as _; + +.container { + width: 100%; + + .title { + font: var(--font-label-2); + margin-bottom: _.unit(1); + } + + table { + width: 100%; + border-spacing: 0; + border: 1px solid var(--color-neutral-variant-90); + border-radius: 8px; + + td { + padding: _.unit(6); + border-bottom: 1px solid var(--color-neutral-variant-90); + + &:first-child { + width: 35%; + } + } + + tr:last-child td { + border-bottom: none; + } + } +} diff --git a/packages/ui/src/pages/Profile/components/Table/index.tsx b/packages/ui/src/pages/Profile/components/Table/index.tsx new file mode 100644 index 000000000..6818651bc --- /dev/null +++ b/packages/ui/src/pages/Profile/components/Table/index.tsx @@ -0,0 +1,44 @@ +import type { I18nKey } from '@logto/phrases-ui'; +import type { ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import * as styles from './index.module.scss'; + +export type Row = { + label: I18nKey; + value: unknown; + renderer?: (value: unknown) => ReactNode; +}; + +type Props = { + title: I18nKey; + data: Row[]; +}; + +const defaultRenderer = (value: unknown) => (value ? String(value) : '-'); + +const Table = ({ title, data }: Props) => { + const { t } = useTranslation(); + + if (data.length === 0) { + return null; + } + + return ( +
+
{t(title)}
+ + + {data.map(({ label, value, renderer = defaultRenderer }) => ( + + + + + ))} + +
{t(label)}{renderer(value)}
+
+ ); +}; + +export default Table; diff --git a/packages/ui/src/pages/Profile/index.module.scss b/packages/ui/src/pages/Profile/index.module.scss new file mode 100644 index 000000000..88f4940e0 --- /dev/null +++ b/packages/ui/src/pages/Profile/index.module.scss @@ -0,0 +1,42 @@ +@use '@/scss/underscore' as _; + +.container { + @include _.flex-column(center, normal); + position: absolute; + inset: 0; + overflow-y: auto; + + .wrapper { + @include _.flex-column(normal, normal); + width: 100%; + max-width: 1200px; + flex: 1; + padding: _.unit(4); + + .header { + margin-top: _.unit(2); + + .title { + font: var(--font-title-1); + margin-bottom: _.unit(1); + } + + .subtitle { + font: var(--font-body-2); + color: var(--color-type-secondary); + } + } + } +} + +:global(body.mobile) { + .container { + background: var(--color-bg-body-base); + } +} + +:global(body.desktop) { + .container { + background: var(--color-surface); + } +} diff --git a/packages/ui/src/pages/Profile/index.tsx b/packages/ui/src/pages/Profile/index.tsx index b023e60ab..319bc4374 100644 --- a/packages/ui/src/pages/Profile/index.tsx +++ b/packages/ui/src/pages/Profile/index.tsx @@ -1,5 +1,63 @@ +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { getUserProfile } from '@/apis/profile'; +import LoadingLayer from '@/components/LoadingLayer'; +import useApi from '@/hooks/use-api'; + +import FormCard from './components/FormCard'; +import Table from './components/Table'; +import * as styles from './index.module.scss'; + const Profile = () => { - return <>Profile works!; + const { t } = useTranslation(); + const { run: asyncGetProfile, result: profile } = useApi(getUserProfile); + + useEffect(() => { + void asyncGetProfile(); + }, [asyncGetProfile]); + + if (!profile) { + return ; + } + + const { avatar, name, username, primaryEmail, primaryPhone } = profile; + + return ( +
+
+
+
{t('profile.title')}
+
{t('profile.description')}
+
+ + + + +
+ + +
+ + + + ); }; export default Profile; diff --git a/packages/ui/src/scss/_colors.scss b/packages/ui/src/scss/_colors.scss index 76aaa2918..60174f940 100644 --- a/packages/ui/src/scss/_colors.scss +++ b/packages/ui/src/scss/_colors.scss @@ -55,6 +55,7 @@ /* Background */ --color-bg-body-base: var(--color-neutral-95); --color-bg-body: var(--color-neutral-100); + --color-bg-layer-1: var(--color-static-white); --color-bg-layer-2: var(--color-neutral-95); --color-bg-body-overlay: var(--color-neutral-100); --color-bg-float-base: var(--color-neutral-variant-90); @@ -93,6 +94,8 @@ --color-overlay-brand-hover: rgba(93, 52, 242, 8%); // 8% --color-brand-default --color-overlay-brand-pressed: rgba(93, 52, 242, 12%); // 12% --color-brand-default --color-overlay-brand-focused: rgba(93, 52, 242, 16%); // 16% --color-brand-default + + --color-surface: var(--color-neutral-99); } @mixin dark { @@ -156,6 +159,10 @@ --color-bg-body-base: var(--color-neutral-100); --color-bg-body: var(--color-surface); --color-bg-body-overlay: var(--color-surface-2); + --color-bg-layer-1: + linear-gradient(0deg, rgba(202, 190, 255, 8%), rgba(202, 190, 255, 8%)), + linear-gradient(0deg, rgba(196, 199, 199, 2%), rgba(196, 199, 199, 2%)), + #191c1d; --color-bg-layer-2: var(--color-surface-4); --color-bg-float-base: var(--color-neutral-100); --color-bg-float: var(--color-surface-2); diff --git a/packages/ui/src/scss/_fonts.scss b/packages/ui/src/scss/_fonts.scss index 60851829e..8286d23ce 100644 --- a/packages/ui/src/scss/_fonts.scss +++ b/packages/ui/src/scss/_fonts.scss @@ -24,4 +24,6 @@ $font-family: --font-body-1: 400 16px/24px #{$font-family}; --font-body-2: 400 14px/20px #{$font-family}; --font-body-3: 400 12px/16px #{$font-family}; + + --font-subhead-cap: 700 12px/16px #{$font-family}; } From c2bf25bd0b3d0257ec677c437f0168ec4e0df758 Mon Sep 17 00:00:00 2001 From: Charles Zhao Date: Fri, 16 Dec 2022 22:50:13 +0800 Subject: [PATCH 7/7] refactor: add desktop and mobile views to render profile on different platforms (#2642) --- packages/core/src/routes/profile.test.ts | 6 +- packages/core/src/routes/profile.ts | 10 ++- packages/phrases-ui/src/locales/de.ts | 6 +- packages/phrases-ui/src/locales/en.ts | 6 +- packages/phrases-ui/src/locales/fr.ts | 6 +- packages/phrases-ui/src/locales/ko.ts | 6 +- packages/phrases-ui/src/locales/pt-br.ts | 6 +- packages/phrases-ui/src/locales/pt-pt.ts | 6 +- packages/phrases-ui/src/locales/tr-tr.ts | 6 +- packages/phrases-ui/src/locales/zh-cn.ts | 6 +- packages/schemas/src/types/user.ts | 2 + packages/ui/src/apis/profile.ts | 6 +- packages/ui/src/assets/icons/arrow-next.svg | 3 + packages/ui/src/assets/icons/nav-close.svg | 3 + .../src/components/NavBar/index.module.scss | 8 +-- packages/ui/src/components/NavBar/index.tsx | 28 +++++--- .../components/NavItem/index.module.scss | 53 ++++++++++++++ .../Profile/components/NavItem/index.tsx | 51 +++++++++++++ .../Profile/containers/DesktopView/index.tsx | 51 +++++++++++++ .../Profile/containers/MobileView/index.tsx | 71 +++++++++++++++++++ .../ui/src/pages/Profile/index.module.scss | 10 +++ packages/ui/src/pages/Profile/index.tsx | 45 ++++-------- 22 files changed, 332 insertions(+), 63 deletions(-) create mode 100644 packages/ui/src/assets/icons/arrow-next.svg create mode 100644 packages/ui/src/assets/icons/nav-close.svg create mode 100644 packages/ui/src/pages/Profile/components/NavItem/index.module.scss create mode 100644 packages/ui/src/pages/Profile/components/NavItem/index.tsx create mode 100644 packages/ui/src/pages/Profile/containers/DesktopView/index.tsx create mode 100644 packages/ui/src/pages/Profile/containers/MobileView/index.tsx diff --git a/packages/core/src/routes/profile.test.ts b/packages/core/src/routes/profile.test.ts index 31d754cc6..36422e02c 100644 --- a/packages/core/src/routes/profile.test.ts +++ b/packages/core/src/routes/profile.test.ts @@ -15,6 +15,7 @@ import { createRequester } from '#src/utils/test-utils.js'; const { jest } = import.meta; +const mockUserProfileResponse = { ...mockUserResponse, hasPasswordSet: true }; const getLogtoConnectorById = jest.fn(async () => ({ dbEntry: { enabled: true }, metadata: { id: 'connectorId', target: 'mock_social' }, @@ -105,7 +106,7 @@ describe('session -> profileRoutes', () => { it('should return current user data', async () => { const response = await sessionRequest.get(profileRoute); expect(response.statusCode).toEqual(200); - expect(response.body).toEqual(mockUserResponse); + expect(response.body).toEqual(mockUserProfileResponse); }); it('should throw when the user is not authenticated', async () => { @@ -170,8 +171,7 @@ describe('session -> profileRoutes', () => { .patch(`${profileRoute}/username`) .send({ username: newUsername }); - expect(response.statusCode).toEqual(200); - expect(response.body).toEqual({ ...mockUserResponse, username: newUsername }); + expect(response.statusCode).toEqual(204); }); it('should throw when username is already in use', async () => { diff --git a/packages/core/src/routes/profile.ts b/packages/core/src/routes/profile.ts index 39d17dfc2..c73a6f5d3 100644 --- a/packages/core/src/routes/profile.ts +++ b/packages/core/src/routes/profile.ts @@ -28,7 +28,11 @@ export default function profileRoutes(router: T, prov const user = await findUserById(userId); - ctx.body = pick(user, ...userInfoSelectFields); + ctx.body = { + ...pick(user, ...userInfoSelectFields), + hasPasswordSet: Boolean(user.passwordEncrypted), + }; + ctx.status = 200; return next(); @@ -69,9 +73,9 @@ export default function profileRoutes(router: T, prov const { username } = ctx.guard.body; await checkIdentifierCollision({ username }, userId); + await updateUserById(userId, { username }, 'replace'); - const user = await updateUserById(userId, { username }, 'replace'); - ctx.body = pick(user, ...userInfoSelectFields); + ctx.status = 204; return next(); } diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index d7de6b42c..ae55c346f 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -99,14 +99,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index ba13b7d40..9f5525f13 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -95,14 +95,18 @@ const translation = { password: { title: 'PASSWORD', reset_password: 'Reset Password', + reset_password_sc: 'Reset password', }, link_account: { title: 'LINK ACCOUNT', email_phone_sign_in: 'Email / Phone Sign-In', email: 'Email', phone: 'Phone', - social_sign_in: 'Social Sign-In', + phone_sc: 'Phone number', + social: 'Social Sign-In', + social_sc: 'Social accounts', }, + not_set: 'Not set', edit: 'Edit', change: 'Change', link: 'Link', diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index fd8d04ebb..f9332f9b7 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -99,14 +99,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index 3fcb8e982..482faad64 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -95,14 +95,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts index 2bd3973ea..e792900af 100644 --- a/packages/phrases-ui/src/locales/pt-br.ts +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -97,14 +97,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index be7c2c9a7..0f8171b85 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -95,14 +95,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 7def7cc39..487f2f07d 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -96,14 +96,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index 2e2855bc2..c6a5d73d0 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -91,14 +91,18 @@ const translation = { password: { title: 'PASSWORD', // UNTRANSLATED reset_password: 'Reset Password', // UNTRANSLATED + reset_password_sc: 'Reset password', // UNTRANSLATED }, link_account: { title: 'LINK ACCOUNT', // UNTRANSLATED email_phone_sign_in: 'Email / Phone Sign-In', // UNTRANSLATED email: 'Email', // UNTRANSLATED phone: 'Phone', // UNTRANSLATED - social_sign_in: 'Social Sign-In', // UNTRANSLATED + phone_sc: 'Phone number', // UNTRANSLATED + social: 'Social Sign-In', // UNTRANSLATED + social_sc: 'Social accounts', // UNTRANSLATED }, + not_set: 'Not set', // UNTRANSLATED edit: 'Edit', // UNTRANSLATED change: 'Change', // UNTRANSLATED link: 'Link', // UNTRANSLATED diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 9e05c76d2..678a3305b 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -21,6 +21,8 @@ export type UserInfo; +export type UserProfileResponse = UserInfo & { hasPasswordSet: boolean }; + export enum UserRole { Admin = 'admin', } diff --git a/packages/ui/src/apis/profile.ts b/packages/ui/src/apis/profile.ts index 5736ec452..2d3fe8f07 100644 --- a/packages/ui/src/apis/profile.ts +++ b/packages/ui/src/apis/profile.ts @@ -1,8 +1,8 @@ -import type { UserInfo } from '@logto/schemas'; +import type { UserProfileResponse } from '@logto/schemas'; import api from './api'; const profileApiPrefix = '/api/profile'; -export const getUserProfile = async (): Promise => - api.get(profileApiPrefix).json(); +export const getUserProfile = async (): Promise => + api.get(profileApiPrefix).json(); diff --git a/packages/ui/src/assets/icons/arrow-next.svg b/packages/ui/src/assets/icons/arrow-next.svg new file mode 100644 index 000000000..481bdf56d --- /dev/null +++ b/packages/ui/src/assets/icons/arrow-next.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/nav-close.svg b/packages/ui/src/assets/icons/nav-close.svg new file mode 100644 index 000000000..37660bd0c --- /dev/null +++ b/packages/ui/src/assets/icons/nav-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/NavBar/index.module.scss b/packages/ui/src/components/NavBar/index.module.scss index 16a6d5273..743da3b55 100644 --- a/packages/ui/src/components/NavBar/index.module.scss +++ b/packages/ui/src/components/NavBar/index.module.scss @@ -18,9 +18,9 @@ } } -.backButton { +.navButton { position: absolute; - left: _.unit(-2); + left: 0; top: 50%; transform: translateY(-50%); font: var(--font-label-2); @@ -29,13 +29,13 @@ } :global(body.mobile) { - .backButton > span { + .navButton > span { display: none; } } :global(body.desktop) { - .backButton { + .navButton { &:hover { text-decoration: underline; } diff --git a/packages/ui/src/components/NavBar/index.tsx b/packages/ui/src/components/NavBar/index.tsx index 8bbe59a9a..1720121a9 100644 --- a/packages/ui/src/components/NavBar/index.tsx +++ b/packages/ui/src/components/NavBar/index.tsx @@ -2,35 +2,41 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import ArrowPrev from '@/assets/icons/arrow-prev.svg'; +import NavClose from '@/assets/icons/nav-close.svg'; import { onKeyDownHandler } from '@/utils/a11y'; import * as styles from './index.module.scss'; type Props = { title?: string; + type?: 'back' | 'close'; }; -const NavBar = ({ title }: Props) => { +const NavBar = ({ title, type = 'back' }: Props) => { const navigate = useNavigate(); const { t } = useTranslation(); + const isClosable = type === 'close'; + + const clickHandler = () => { + if (isClosable) { + window.close(); + } + + navigate(-1); + }; return (
{ - navigate(-1); - })} - onClick={() => { - navigate(-1); - }} + className={styles.navButton} + onKeyDown={onKeyDownHandler(clickHandler)} + onClick={clickHandler} > - - {t('action.nav_back')} + {isClosable ? : } + {!isClosable && {t('action.nav_back')}}
- {title &&
{title}
}
); diff --git a/packages/ui/src/pages/Profile/components/NavItem/index.module.scss b/packages/ui/src/pages/Profile/components/NavItem/index.module.scss new file mode 100644 index 000000000..9b53b2bae --- /dev/null +++ b/packages/ui/src/pages/Profile/components/NavItem/index.module.scss @@ -0,0 +1,53 @@ +@use '@/scss/underscore' as _; + +.container { + display: flex; + flex-direction: column; + width: 100%; + margin-top: _.unit(3); + background: var(--color-bg-layer-1); + + .item { + padding-left: _.unit(5); + + .wrapper { + display: flex; + flex: 1; + align-items: center; + padding: _.unit(3) 0; + } + + &:active { + background: var(--color-overlay-neutral-pressed); + } + } + + .item + .item { + .wrapper { + border-top: 1px solid var(--color-line-divider); + } + } + + .content { + flex: 1; + display: flex; + flex-direction: column; + + .label { + font: var(--font-body-1); + } + + .value { + font: var(--font-body-2); + color: var(--color-type-secondary); + margin-top: _.unit(0.5); + } + } + + .action { + display: flex; + align-items: center; + padding: 0 _.unit(4) 0 _.unit(3); + color: var(--color-type-secondary); + } +} diff --git a/packages/ui/src/pages/Profile/components/NavItem/index.tsx b/packages/ui/src/pages/Profile/components/NavItem/index.tsx new file mode 100644 index 000000000..1463737c4 --- /dev/null +++ b/packages/ui/src/pages/Profile/components/NavItem/index.tsx @@ -0,0 +1,51 @@ +import type { I18nKey } from '@logto/phrases-ui'; +import type { Nullable } from '@silverhand/essentials'; +import { useTranslation } from 'react-i18next'; + +import ArrowNext from '@/assets/icons/arrow-next.svg'; +import { onKeyDownHandler } from '@/utils/a11y'; + +import * as styles from './index.module.scss'; + +type Item = { + label: I18nKey; + value?: Nullable; + onTap: () => void; +}; + +type Props = { + data: Item[]; +}; + +const NavItem = ({ data }: Props) => { + const { t } = useTranslation(); + + return ( +
+ {data.map(({ label, value, onTap }) => ( +
+
+
+
{t(label)}
+ {value &&
{value}
} +
+
+ +
+
+
+ ))} +
+ ); +}; + +export default NavItem; diff --git a/packages/ui/src/pages/Profile/containers/DesktopView/index.tsx b/packages/ui/src/pages/Profile/containers/DesktopView/index.tsx new file mode 100644 index 000000000..af6a7b8dd --- /dev/null +++ b/packages/ui/src/pages/Profile/containers/DesktopView/index.tsx @@ -0,0 +1,51 @@ +import type { UserProfileResponse } from '@logto/schemas'; +import { useTranslation } from 'react-i18next'; + +import FormCard from '../../components/FormCard'; +import Table from '../../components/Table'; + +type Props = { + profile: UserProfileResponse; +}; + +const DesktopView = ({ profile }: Props) => { + const { t } = useTranslation(); + const { avatar, name, username, primaryEmail, primaryPhone, hasPasswordSet } = profile; + + return ( + <> + +
+ + +
+ + +
+ + + ); +}; + +export default DesktopView; diff --git a/packages/ui/src/pages/Profile/containers/MobileView/index.tsx b/packages/ui/src/pages/Profile/containers/MobileView/index.tsx new file mode 100644 index 000000000..40f3441b5 --- /dev/null +++ b/packages/ui/src/pages/Profile/containers/MobileView/index.tsx @@ -0,0 +1,71 @@ +import type { UserProfileResponse } from '@logto/schemas'; +import { useTranslation } from 'react-i18next'; + +import NavItem from '../../components/NavItem'; + +type Props = { + profile: UserProfileResponse; +}; + +const MobileView = ({ profile }: Props) => { + const { t } = useTranslation(); + + const { username, primaryEmail, primaryPhone, hasPasswordSet, identities } = profile; + const socialConnectorNames = identities?.length + ? Object.keys(identities).join(', ') + : t('profile.not_set'); + + return ( + <> + { + console.log('username'); + }, + }, + ]} + /> + { + console.log('password'); + }, + }, + ]} + /> + { + console.log('email'); + }, + }, + { + label: 'profile.link_account.phone_sc', + value: primaryPhone ?? t('profile.not_set'), + onTap: () => { + console.log('phone'); + }, + }, + { + label: 'profile.link_account.social_sc', + value: socialConnectorNames, + onTap: () => { + console.log('social accounts'); + }, + }, + ]} + /> + + ); +}; + +export default MobileView; diff --git a/packages/ui/src/pages/Profile/index.module.scss b/packages/ui/src/pages/Profile/index.module.scss index 88f4940e0..1a085e35f 100644 --- a/packages/ui/src/pages/Profile/index.module.scss +++ b/packages/ui/src/pages/Profile/index.module.scss @@ -32,6 +32,16 @@ :global(body.mobile) { .container { background: var(--color-bg-body-base); + + .wrapper { + padding: 0; + + .header { + margin: 0; + padding: 0 _.unit(4); + background: var(--color-bg-layer-1); + } + } } } diff --git a/packages/ui/src/pages/Profile/index.tsx b/packages/ui/src/pages/Profile/index.tsx index 319bc4374..b1d1f6f5b 100644 --- a/packages/ui/src/pages/Profile/index.tsx +++ b/packages/ui/src/pages/Profile/index.tsx @@ -3,15 +3,19 @@ import { useTranslation } from 'react-i18next'; import { getUserProfile } from '@/apis/profile'; import LoadingLayer from '@/components/LoadingLayer'; +import NavBar from '@/components/NavBar'; import useApi from '@/hooks/use-api'; +import usePlatform from '@/hooks/use-platform'; -import FormCard from './components/FormCard'; -import Table from './components/Table'; +import DesktopView from './containers/DesktopView'; +import MobileView from './containers/MobileView'; import * as styles from './index.module.scss'; const Profile = () => { const { t } = useTranslation(); + const { isMobile } = usePlatform(); const { run: asyncGetProfile, result: profile } = useApi(getUserProfile); + const ContainerView = isMobile ? MobileView : DesktopView; useEffect(() => { void asyncGetProfile(); @@ -21,40 +25,19 @@ const Profile = () => { return ; } - const { avatar, name, username, primaryEmail, primaryPhone } = profile; - return (
-
{t('profile.title')}
-
{t('profile.description')}
+ {isMobile && } + {!isMobile && ( + <> +
{t('profile.title')}
+
{t('profile.description')}
+ + )}
- -
- - -
- - -
- + );