diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 58635272d..f7dfbc946 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -97,6 +97,7 @@ jobs: # Test - name: Run tests + # continue-on-error: true # Uncomment this line to debug run: | cd tests/packages/integration-tests pnpm build diff --git a/packages/cli/src/commands/database/seed/tables.ts b/packages/cli/src/commands/database/seed/tables.ts index 6338175be..41d3709d3 100644 --- a/packages/cli/src/commands/database/seed/tables.ts +++ b/packages/cli/src/commands/database/seed/tables.ts @@ -2,13 +2,14 @@ import { readdir, readFile } from 'fs/promises'; import path from 'path'; import { - defaultSignInExperience, createDefaultAdminConsoleConfig, defaultTenantId, adminTenantId, defaultManagementApi, createAdminDataInAdminTenant, createMeApiInAdminTenant, + createDefaultSignInExperience, + createAdminTenantSignInExperience, } from '@logto/schemas'; import { Hooks, Tenants } from '@logto/schemas/models'; import type { DatabaseTransactionConnection } from 'slonik'; @@ -123,7 +124,10 @@ export const seedTables = async ( await Promise.all([ connection.query(insertInto(createDefaultAdminConsoleConfig(defaultTenantId), 'logto_configs')), - connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), + connection.query( + insertInto(createDefaultSignInExperience(defaultTenantId), 'sign_in_experiences') + ), + connection.query(insertInto(createAdminTenantSignInExperience(), 'sign_in_experiences')), updateDatabaseTimestamp(connection, latestTimestamp), ]); }; diff --git a/packages/core/src/libraries/session.ts b/packages/core/src/libraries/session.ts index 64366adba..a1262c868 100644 --- a/packages/core/src/libraries/session.ts +++ b/packages/core/src/libraries/session.ts @@ -1,7 +1,6 @@ import type { Context } from 'koa'; import type { InteractionResults } from 'oidc-provider'; import type Provider from 'oidc-provider'; -import { errors } from 'oidc-provider'; import type Queries from '#src/tenants/Queries.js'; @@ -45,27 +44,3 @@ export const saveUserFirstConsentedAppId = async ( await updateUserById(userId, { applicationId }); } }; - -export const getApplicationIdFromInteraction = async ( - ctx: Context, - provider: Provider -): Promise => { - const interaction = await provider - .interactionDetails(ctx.req, ctx.res) - .catch((error: unknown) => { - // Should not block if interaction is not found - if (error instanceof errors.SessionNotFound) { - return null; - } - - throw error; - }); - - if (!interaction?.params) { - return; - } - - return typeof interaction.params.client_id === 'string' - ? interaction.params.client_id - : undefined; -}; diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 43d035b7d..2bc534464 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -1,14 +1,6 @@ import { builtInLanguages } from '@logto/phrases-ui'; import type { Branding, LanguageInfo, SignInExperience } from '@logto/schemas'; -import { - adminTenantId, - SignInMode, - ConnectorType, - BrandingStyle, - adminConsoleApplicationId, - adminConsoleSignInExperience, - demoAppApplicationId, -} from '@logto/schemas'; +import { ConnectorType, BrandingStyle } from '@logto/schemas'; import { deduplicate } from '@silverhand/essentials'; import i18next from 'i18next'; @@ -69,49 +61,20 @@ export const createSignInExperienceLibrary = ( }); }; - const getSignInExperienceForApplication = async ( - applicationId?: string - ): Promise => { - // Hard code Admin Console sign-in methods settings. - if (applicationId === adminConsoleApplicationId) { - return { - ...adminConsoleSignInExperience, - tenantId: adminTenantId, - branding: { - ...adminConsoleSignInExperience.branding, - slogan: i18next.t('admin_console.welcome.title'), - }, - termsOfUseUrl: null, - languageInfo: { - autoDetect: true, - fallbackLanguage: 'en', - }, - signInMode: (await hasActiveUsers()) ? SignInMode.SignIn : SignInMode.Register, - socialSignInConnectorTargets: [], - }; - } + const getSignInExperience = async (): Promise => { + const raw = await findDefaultSignInExperience(); + const { branding } = raw; - const signInExperience = await findDefaultSignInExperience(); - - // Insert Demo App Notification - if (applicationId === demoAppApplicationId) { - const { socialSignInConnectorTargets } = signInExperience; - - const notification = i18next.t('demo_app.notification'); - - return { - ...signInExperience, - socialSignInConnectorTargets, - notification, - }; - } - - return signInExperience; + // Alter sign-in experience dynamic configs + return Object.freeze({ + ...raw, + branding: { ...branding, slogan: branding.slogan && i18next.t(branding.slogan) }, + }); }; return { validateLanguageInfo, removeUnavailableSocialConnectorTargets, - getSignInExperienceForApplication, + getSignInExperience, }; }; diff --git a/packages/core/src/libraries/sign-in-experience/sign-up.test.ts b/packages/core/src/libraries/sign-in-experience/sign-up.test.ts index ebfd56578..6084f6a16 100644 --- a/packages/core/src/libraries/sign-in-experience/sign-up.test.ts +++ b/packages/core/src/libraries/sign-in-experience/sign-up.test.ts @@ -9,10 +9,6 @@ const { mockEsmWithActual } = createMockUtils(jest); const enabledConnectors = [mockAliyunDmConnector, mockAliyunSmsConnector]; -await mockEsmWithActual('#src/libraries/session.js', () => ({ - getApplicationIdFromInteraction: jest.fn(), -})); - const { validateSignUp } = await import('./sign-up.js'); describe('validate sign-up', () => { diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index 0f17f40e9..4cb599726 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'fs'; import { userClaims } from '@logto/core-kit'; -import { CustomClientMetadataKey } from '@logto/schemas'; +import { CustomClientMetadataKey, demoAppApplicationId } from '@logto/schemas'; import Provider, { errors, ResourceServer } from 'oidc-provider'; import snakecaseKeys from 'snakecase-keys'; @@ -129,9 +129,16 @@ export default function initOidc(envSet: EnvSet, queries: Queries, libraries: Li }, interactions: { url: (_, interaction) => { + const appendParameters = (path: string) => { + // `notification` is for showing a text banner on the homepage + return interaction.params.client_id === demoAppApplicationId + ? path + `?notification=demo_app.notification` + : path; + }; + switch (interaction.prompt.name) { case 'login': { - return routes.signIn.credentials; + return appendParameters(routes.signIn.credentials); } case 'consent': { diff --git a/packages/core/src/routes/consts.ts b/packages/core/src/routes/consts.ts index d1099ad24..9d91d1c3b 100644 --- a/packages/core/src/routes/consts.ts +++ b/packages/core/src/routes/consts.ts @@ -3,9 +3,9 @@ const signIn = '/sign-in'; export const routes = Object.freeze({ signIn: { credentials: signIn, - consent: signIn + '/consent', + consent: `${signIn}/consent`, }, -}); +} as const); export const verificationTimeout = 10 * 60; // 10 mins. export const continueSignInTimeout = 10 * 60; // 10 mins. diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index 4422a8439..6633df06e 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -55,7 +55,7 @@ jest.useFakeTimers().setSystemTime(now); describe('submit action', () => { const tenant = new MockTenant( undefined, - { users: userQueries }, + { users: userQueries, signInExperiences: { updateDefaultSignInExperience: jest.fn() } }, { users: userLibraries, connectors: { getLogtoConnectorById } } ); const ctx = { diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index 111a76bf4..9a17fe038 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -1,5 +1,6 @@ import type { User, Profile } from '@logto/schemas'; import { + SignInMode, UserRole, getManagementApiAdminName, defaultTenantId, @@ -151,6 +152,7 @@ export default async function submitInteraction( log?: LogEntry ) { const { hasActiveUsers, findUserById, updateUserById } = queries.users; + const { updateDefaultSignInExperience } = queries.signInExperiences; const { users: { generateUserId, insertUser }, @@ -164,7 +166,7 @@ export default async function submitInteraction( const { client_id } = ctx.interactionDetails.params; - const createAdminUser = + const isCreatingFirstAdminUser = getTenantId(ctx.URL) === adminTenantId && String(client_id) === adminConsoleApplicationId && !(await hasActiveUsers()); @@ -174,9 +176,15 @@ export default async function submitInteraction( id, ...upsertProfile, }, - createAdminUser ? [UserRole.User, getManagementApiAdminName(defaultTenantId)] : [] + isCreatingFirstAdminUser ? [UserRole.User, getManagementApiAdminName(defaultTenantId)] : [] ); + // In OSS, we need to limit sign-in experience to "sign-in only" once + // the first admin has been create since we don't want other unexpected registrations + if (isCreatingFirstAdminUser) { + await updateDefaultSignInExperience({ signInMode: SignInMode.SignIn }); + } + await assignInteractionResults(ctx, provider, { login: { accountId: id } }); log?.append({ userId: id }); diff --git a/packages/core/src/routes/interaction/index.test.ts b/packages/core/src/routes/interaction/index.test.ts index 558420c70..09dd93c5b 100644 --- a/packages/core/src/routes/interaction/index.test.ts +++ b/packages/core/src/routes/interaction/index.test.ts @@ -117,7 +117,7 @@ const tenantContext = new MockTenant( }, }, signInExperiences: { - getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience), + getSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience), }, } ); diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts index 23c3ad9a3..8cd0afa2e 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-sie.ts @@ -1,5 +1,4 @@ import type { SignInExperience } from '@logto/schemas'; -import { conditional } from '@silverhand/essentials'; import type { MiddlewareType } from 'koa'; import type { SignInExperienceLibrary } from '#src/libraries/sign-in-experience/index.js'; @@ -11,21 +10,14 @@ export type WithInteractionSieContext = WithInteractionDetailsContext< }; export default function koaInteractionSie({ - getSignInExperienceForApplication, + getSignInExperience, }: SignInExperienceLibrary): MiddlewareType< StateT, WithInteractionSieContext, ResponseT > { return async (ctx, next) => { - const { interactionDetails } = ctx; - - const signInExperience = await getSignInExperienceForApplication( - conditional( - typeof interactionDetails.params.client_id === 'string' && - interactionDetails.params.client_id - ) - ); + const signInExperience = await getSignInExperience(); ctx.signInExperience = signInExperience; diff --git a/packages/core/src/routes/phrase.test.ts b/packages/core/src/routes/phrase.test.ts index 1b0378045..a69953ea9 100644 --- a/packages/core/src/routes/phrase.test.ts +++ b/packages/core/src/routes/phrase.test.ts @@ -1,6 +1,5 @@ import zhCN from '@logto/phrases-ui/lib/locales/zh-cn.js'; import type { CustomPhrase, SignInExperience } from '@logto/schemas'; -import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas'; import { pickDefault, createMockUtils } from '@logto/shared/esm'; import { zhCnTag } from '#src/__mocks__/custom-phrase.js'; @@ -44,9 +43,8 @@ const { findAllCustomLanguageTags } = customPhrases; const getPhrases = jest.fn(async () => zhCN); -const interactionDetails = jest.fn(); const tenantContext = new MockTenant( - createMockProvider(interactionDetails), + createMockProvider(), { customPhrases, signInExperiences: { findDefaultSignInExperience } }, { phrases: { getPhrases } } ); @@ -59,64 +57,11 @@ const phraseRequest = createRequester({ tenantContext, }); -describe('when the application is admin-console', () => { - beforeEach(() => { - interactionDetails.mockResolvedValueOnce({ - params: { client_id: adminConsoleApplicationId }, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should call interactionDetails', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(interactionDetails).toBeCalledTimes(1); - }); - - it('should not call findDefaultSignInExperience', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(findDefaultSignInExperience).not.toBeCalled(); - }); - - it('should call detectLanguage', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(detectLanguageSpy).toBeCalledTimes(1); - }); - - it('should call findAllCustomLanguageTags', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(findAllCustomLanguageTags).toBeCalledTimes(1); - }); - - it('should call getPhrases with fallback language from Admin Console sign-in experience', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(getPhrases).toBeCalledTimes(1); - expect(getPhrases).toBeCalledWith(adminConsoleSignInExperience.languageInfo.fallbackLanguage, [ - customizedLanguage, - ]); - }); -}); - describe('when the application is not admin-console', () => { - beforeEach(() => { - interactionDetails.mockResolvedValue({ - params: {}, - jti: 'jti', - client_id: 'mockApplicationId', - }); - }); - afterEach(() => { jest.clearAllMocks(); }); - it('should call interactionDetails', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); - expect(interactionDetails).toBeCalledTimes(1); - }); - it('should call findDefaultSignInExperience', async () => { await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); expect(findDefaultSignInExperience).toBeCalledTimes(1); diff --git a/packages/core/src/routes/phrase.ts b/packages/core/src/routes/phrase.ts index 41dae0891..1ef261636 100644 --- a/packages/core/src/routes/phrase.ts +++ b/packages/core/src/routes/phrase.ts @@ -1,5 +1,4 @@ import { isBuiltInLanguageTag } from '@logto/phrases-ui'; -import { adminConsoleApplicationId, adminConsoleSignInExperience } from '@logto/schemas'; import { object, string } from 'zod'; import detectLanguage from '#src/i18n/detect-language.js'; @@ -8,7 +7,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; export default function phraseRoutes( - ...[router, { provider, queries, libraries }]: RouterInitArgs + ...[router, { queries, libraries }]: RouterInitArgs ) { const { customPhrases: { findAllCustomLanguageTags }, @@ -16,11 +15,7 @@ export default function phraseRoutes( } = queries; const { getPhrases } = libraries.phrases; - const getLanguageInfo = async (applicationId: unknown) => { - if (applicationId === adminConsoleApplicationId) { - return adminConsoleSignInExperience.languageInfo; - } - + const getLanguageInfo = async () => { const { languageInfo } = await findDefaultSignInExperience(); return languageInfo; @@ -34,17 +29,11 @@ export default function phraseRoutes( }), }), async (ctx, next) => { - const interaction = await provider - .interactionDetails(ctx.req, ctx.res) - // Should not block when failed to get interaction - .catch(() => null); - const { query: { lng }, } = ctx.guard; - const applicationId = interaction?.params.client_id; - const { autoDetect, fallbackLanguage } = await getLanguageInfo(applicationId); + const { autoDetect, fallbackLanguage } = await getLanguageInfo(); const targetLanguage = lng ? [lng] : []; const detectedLanguages = autoDetect ? detectLanguage(ctx) : []; diff --git a/packages/core/src/routes/well-known.test.ts b/packages/core/src/routes/well-known.test.ts index 11e50589f..b80325f84 100644 --- a/packages/core/src/routes/well-known.test.ts +++ b/packages/core/src/routes/well-known.test.ts @@ -1,8 +1,3 @@ -import { - SignInMode, - adminConsoleApplicationId, - adminConsoleSignInExperience, -} from '@logto/schemas'; import { pickDefault, createMockUtils } from '@logto/shared/esm'; import { @@ -100,26 +95,4 @@ describe('GET /.well-known/sign-in-exp', () => { ], }); }); - - it('should return admin console settings', async () => { - jest - .spyOn(provider, 'interactionDetails') - // @ts-expect-error - .mockResolvedValue({ params: { client_id: adminConsoleApplicationId } }); - const response = await sessionRequest.get('/.well-known/sign-in-exp'); - expect(response.status).toEqual(200); - - expect(response.body).toMatchObject({ - ...adminConsoleSignInExperience, - tenantId: 'admin', - branding: { - ...adminConsoleSignInExperience.branding, - slogan: 'admin_console.welcome.title', - }, - termsOfUseUrl: null, - languageInfo: mockSignInExperience.languageInfo, - socialConnectors: [], - signInMode: SignInMode.SignIn, - }); - }); }); diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 9696a5a1f..352dfcd13 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -1,19 +1,18 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorType } from '@logto/connector-kit'; -import { adminConsoleApplicationId, adminTenantId } from '@logto/schemas'; +import { adminTenantId } from '@logto/schemas'; import etag from 'etag'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; -import { getApplicationIdFromInteraction } from '#src/libraries/session.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; export default function wellKnownRoutes( - ...[router, { provider, libraries, id }]: RouterInitArgs + ...[router, { libraries, id }]: RouterInitArgs ) { const { - signInExperiences: { getSignInExperienceForApplication }, + signInExperiences: { getSignInExperience }, connectors: { getLogtoConnectors }, } = libraries; @@ -34,10 +33,8 @@ export default function wellKnownRoutes( router.get( '/.well-known/sign-in-exp', async (ctx, next) => { - const applicationId = await getApplicationIdFromInteraction(ctx, provider); - const [signInExperience, logtoConnectors] = await Promise.all([ - getSignInExperienceForApplication(applicationId), + getSignInExperience(), getLogtoConnectors(), ]); @@ -46,21 +43,18 @@ export default function wellKnownRoutes( email: logtoConnectors.some(({ type }) => type === ConnectorType.Email), }; - const socialConnectors = - applicationId === adminConsoleApplicationId - ? [] - : signInExperience.socialSignInConnectorTargets.reduce< - Array - >((previous, connectorTarget) => { - const connectors = logtoConnectors.filter( - ({ metadata: { target } }) => target === connectorTarget - ); + const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce< + Array + >((previous, connectorTarget) => { + const connectors = logtoConnectors.filter( + ({ metadata: { target } }) => target === connectorTarget + ); - return [ - ...previous, - ...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })), - ]; - }, []); + return [ + ...previous, + ...connectors.map(({ metadata, dbEntry: { id } }) => ({ ...metadata, id })), + ]; + }, []); ctx.body = { ...signInExperience, diff --git a/packages/integration-tests/jest.config.js b/packages/integration-tests/jest.config.js index ed51ac280..2c1f814ce 100644 --- a/packages/integration-tests/jest.config.js +++ b/packages/integration-tests/jest.config.js @@ -3,7 +3,7 @@ const config = { transform: {}, testPathIgnorePatterns: ['/node_modules/'], coverageProvider: 'v8', - setupFilesAfterEnv: ['./jest.setup.js'], + setupFilesAfterEnv: ['./jest.setup.js', './jest.setup.api.js'], roots: ['./lib'], moduleNameMapper: { '^#src/(.*)\\.js(x)?$': '/lib/$1', diff --git a/packages/integration-tests/jest.setup.api.js b/packages/integration-tests/jest.setup.api.js new file mode 100644 index 000000000..d31c2670d --- /dev/null +++ b/packages/integration-tests/jest.setup.api.js @@ -0,0 +1,7 @@ +import { authedAdminTenantApi } from './lib/api/api.js'; + +// We need to update this before tests otherwise Logto will update SignInMode for admin tenant +// The update logic should be +await authedAdminTenantApi.patch('sign-in-exp', { + json: { signInMode: 'SignInAndRegister' }, +}); diff --git a/packages/integration-tests/src/api/index.ts b/packages/integration-tests/src/api/index.ts index 932d4916d..62f43a9e3 100644 --- a/packages/integration-tests/src/api/index.ts +++ b/packages/integration-tests/src/api/index.ts @@ -5,7 +5,6 @@ export * from './sign-in-experience.js'; export * from './admin-user.js'; export * from './logs.js'; export * from './dashboard.js'; -export * from './wellknown.js'; export * from './interaction.js'; export { default as api, authedAdminApi } from './api.js'; diff --git a/packages/integration-tests/src/api/wellknown.ts b/packages/integration-tests/src/api/wellknown.ts deleted file mode 100644 index 29d9aa114..000000000 --- a/packages/integration-tests/src/api/wellknown.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SignInExperience } from '@logto/schemas'; - -import api from './api.js'; - -export const getWellKnownSignInExperience = (interactionCookie: string) => - api - .get('.well-known/sign-in-exp', { - headers: { - cookie: interactionCookie, - }, - }) - .json(); diff --git a/packages/integration-tests/src/client/index.ts b/packages/integration-tests/src/client/index.ts index 9c2c19d3e..557839d5d 100644 --- a/packages/integration-tests/src/client/index.ts +++ b/packages/integration-tests/src/client/index.ts @@ -76,7 +76,7 @@ export default class MockClient { // Note: should redirect to sign-in page assert( - response.statusCode === 303 && response.headers.location === '/sign-in', + response.statusCode === 303 && response.headers.location?.startsWith('/sign-in'), new Error('Visit sign in uri failed') ); diff --git a/packages/integration-tests/src/tests/api/well-known.test.ts b/packages/integration-tests/src/tests/api/well-known.test.ts new file mode 100644 index 000000000..388f543c4 --- /dev/null +++ b/packages/integration-tests/src/tests/api/well-known.test.ts @@ -0,0 +1,36 @@ +import type { SignInExperience } from '@logto/schemas'; + +import { adminTenantApi } from '#src/api/api.js'; +import { api } from '#src/api/index.js'; + +describe('.well-known api', () => { + it('get /.well-known/sign-in-exp for console', async () => { + const response = await adminTenantApi.get('.well-known/sign-in-exp').json(); + + expect(response).toMatchObject({ + signUp: { + identifiers: ['username'], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: 'username', + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + signInMode: 'SignInAndRegister', + }); + }); + + it('get /.well-known/sign-in-exp for general app', async () => { + const response = await api.get('.well-known/sign-in-exp').json(); + + // Should support sign-in and register + expect(response).toMatchObject({ signInMode: 'SignInAndRegister' }); + }); +}); diff --git a/packages/integration-tests/src/tests/api/wellknown.test.ts b/packages/integration-tests/src/tests/api/wellknown.test.ts deleted file mode 100644 index 7661ec388..000000000 --- a/packages/integration-tests/src/tests/api/wellknown.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { adminConsoleApplicationId } from '@logto/schemas'; -import { assert } from '@silverhand/essentials'; - -import { getWellKnownSignInExperience } from '#src/api/index.js'; -import MockClient from '#src/client/index.js'; -import { adminConsoleRedirectUri } from '#src/constants.js'; - -describe('wellknown api', () => { - it('get /.well-known/sign-in-exp for AC', async () => { - const client = new MockClient({ appId: adminConsoleApplicationId }); - await client.initSession(adminConsoleRedirectUri); - - assert(client.interactionCookie, new Error('Session not found')); - - const response = await getWellKnownSignInExperience(client.interactionCookie); - - expect(response).toMatchObject({ - signUp: { - identifiers: ['username'], - password: true, - verify: false, - }, - signIn: { - methods: [ - { - identifier: 'username', - password: true, - verificationCode: false, - isPasswordPrimary: true, - }, - ], - }, - signInMode: 'SignIn', - }); - }); - - it('get /.well-known/sign-in-exp for general app', async () => { - const client = new MockClient(); - - await client.initSession(); - - assert(client.interactionCookie, new Error('Session not found')); - - const response = await getWellKnownSignInExperience(client.interactionCookie); - - // Should support sign-in and register - expect(response).toMatchObject({ signInMode: 'SignInAndRegister' }); - }); -}); diff --git a/packages/phrases-ui/src/locales/de.ts b/packages/phrases-ui/src/locales/de.ts index 8c5343beb..26e2af99b 100644 --- a/packages/phrases-ui/src/locales/de.ts +++ b/packages/phrases-ui/src/locales/de.ts @@ -112,6 +112,10 @@ const translation = { unknown: 'Unbekannter Fehler. Versuche es später noch einmal.', invalid_session: 'Die Sitzung ist ungültig. Bitte melde dich erneut an.', }, + demo_app: { + notification: + 'Nutze dein existierendes Admin Konto oder erstelle ein neues Konto um dich in die Demo App einzuloggen.', + }, }; const de: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/en.ts b/packages/phrases-ui/src/locales/en.ts index c6d8c0250..7358685a9 100644 --- a/packages/phrases-ui/src/locales/en.ts +++ b/packages/phrases-ui/src/locales/en.ts @@ -107,6 +107,10 @@ const translation = { unknown: 'Unknown error. Please try again later.', invalid_session: 'Session not found. Please go back and sign in again.', }, + demo_app: { + notification: + 'Use your default admin account or create a new account to sign in to the demo app.', + }, }; const en = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/fr.ts b/packages/phrases-ui/src/locales/fr.ts index e9e93ca70..770618b5c 100644 --- a/packages/phrases-ui/src/locales/fr.ts +++ b/packages/phrases-ui/src/locales/fr.ts @@ -113,6 +113,10 @@ const translation = { invalid_session: 'Session non trouvée. Veuillez revenir en arrière et vous connecter à nouveau.', }, + demo_app: { + notification: + 'Utilisez votre compte administrateur par défaut ou créez un nouveau compte pour vous connecter à la démo.', + }, }; const fr: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/ko.ts b/packages/phrases-ui/src/locales/ko.ts index d539aa4a1..ca42d8002 100644 --- a/packages/phrases-ui/src/locales/ko.ts +++ b/packages/phrases-ui/src/locales/ko.ts @@ -106,6 +106,10 @@ const translation = { unknown: '알 수 없는 오류가 발생했어요. 잠시 후에 시도해 주세요.', invalid_session: '세션을 찾을 수 없어요. 다시 로그인해 주세요.', }, + demo_app: { + notification: + '체험 App에 로그인 하기 위해 관리자 정보 또는 게정을 새로 생성하여 로그인해보세요.', + }, }; const ko: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/pt-br.ts b/packages/phrases-ui/src/locales/pt-br.ts index 1a5add0b0..f6a930f7b 100644 --- a/packages/phrases-ui/src/locales/pt-br.ts +++ b/packages/phrases-ui/src/locales/pt-br.ts @@ -110,6 +110,10 @@ const translation = { unknown: 'Erro desconhecido. Por favor, tente novamente mais tarde.', invalid_session: 'Sessão não encontrada. Volte e faça login novamente.', }, + demo_app: { + notification: + 'Use sua conta de administrador padrão ou crie uma nova conta para entrar no aplicativo de demonstração.', + }, }; const ptBR: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/pt-pt.ts b/packages/phrases-ui/src/locales/pt-pt.ts index 58abdebfc..0f4348cf1 100644 --- a/packages/phrases-ui/src/locales/pt-pt.ts +++ b/packages/phrases-ui/src/locales/pt-pt.ts @@ -108,6 +108,10 @@ const translation = { unknown: 'Erro desconhecido. Por favor, tente novamente mais tarde.', invalid_session: 'Sessão não encontrada. Volte e faça login novamente.', }, + demo_app: { + notification: + 'Use a sua conta de administrador padrão ou crie uma nova conta para entrar na app de demonstração.', + }, }; const ptPT: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/ru.ts b/packages/phrases-ui/src/locales/ru.ts index 912c35454..512108e33 100644 --- a/packages/phrases-ui/src/locales/ru.ts +++ b/packages/phrases-ui/src/locales/ru.ts @@ -111,6 +111,10 @@ const translation = { unknown: 'Неизвестная ошибка. Пожалуйста, повторите попытку позднее.', invalid_session: 'Сессия не найдена. Пожалуйста, войдите снова.', }, + demo_app: { + notification: + 'Nutze dein existierendes Admin Konto oder erstelle ein neues Konto um dich in die Demo App einzuloggen.', + }, }; const ru: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/tr-tr.ts b/packages/phrases-ui/src/locales/tr-tr.ts index 2cf5c7098..47c3ea31d 100644 --- a/packages/phrases-ui/src/locales/tr-tr.ts +++ b/packages/phrases-ui/src/locales/tr-tr.ts @@ -108,6 +108,9 @@ const translation = { unknown: 'Bilinmeyen hata. Lütfen daha sonra tekrar deneyiniz.', invalid_session: 'Oturum bulunamadı. Lütfen geri dönüp tekrar giriş yapınız.', }, + demo_app: { + notification: 'Demo uygulamaya giriş yapmak için yönetici konsolunu kullanınız.', + }, }; const trTR: LocalePhrase = Object.freeze({ diff --git a/packages/phrases-ui/src/locales/zh-cn.ts b/packages/phrases-ui/src/locales/zh-cn.ts index e424de3e0..ca9c95c42 100644 --- a/packages/phrases-ui/src/locales/zh-cn.ts +++ b/packages/phrases-ui/src/locales/zh-cn.ts @@ -104,6 +104,10 @@ const translation = { unknown: '未知错误,请稍后重试。', invalid_session: '未找到会话,请返回并重新登录。', }, + demo_app: { + notification: + '管理控制台的用户名和密码是 demo app 的默认登录方式。点击下方创建账号或用现有账号登录。', + }, }; const zhCN: LocalePhrase = Object.freeze({ diff --git a/packages/phrases/src/locales/de/translation/demo-app.ts b/packages/phrases/src/locales/de/translation/demo-app.ts index 18d473be0..fa4ea2a67 100644 --- a/packages/phrases/src/locales/de/translation/demo-app.ts +++ b/packages/phrases/src/locales/de/translation/demo-app.ts @@ -1,6 +1,4 @@ const demo_app = { - notification: - 'Nutze dein existierendes Admin Konto oder erstelle ein neues Konto um dich in die Demo App einzuloggen.', title: 'Du hast dich erfolgreich in der Demo App angemeldet!', subtitle: 'Here is your log in information:', username: 'Benutzername: ', diff --git a/packages/phrases/src/locales/en/translation/demo-app.ts b/packages/phrases/src/locales/en/translation/demo-app.ts index 1965f890c..1e85c3d60 100644 --- a/packages/phrases/src/locales/en/translation/demo-app.ts +++ b/packages/phrases/src/locales/en/translation/demo-app.ts @@ -1,6 +1,4 @@ const demo_app = { - notification: - 'Use your default admin account or create a new account to sign in to the demo app.', title: "You've successfully signed in the demo app!", subtitle: 'Here is your log in information:', username: 'Username: ', diff --git a/packages/phrases/src/locales/fr/translation/demo-app.ts b/packages/phrases/src/locales/fr/translation/demo-app.ts index a9bad1d28..bef9d66a8 100644 --- a/packages/phrases/src/locales/fr/translation/demo-app.ts +++ b/packages/phrases/src/locales/fr/translation/demo-app.ts @@ -1,6 +1,4 @@ const demo_app = { - notification: - 'Utilisez votre compte administrateur par défaut ou créez un nouveau compte pour vous connecter à la démo.', title: 'Vous avez réussi à vous connecter à la démo !', subtitle: 'Voici vos informations de connexion :', username: "Nom d'utilisateur : ", diff --git a/packages/phrases/src/locales/ko/translation/demo-app.ts b/packages/phrases/src/locales/ko/translation/demo-app.ts index 539254ac8..d39bd45e4 100644 --- a/packages/phrases/src/locales/ko/translation/demo-app.ts +++ b/packages/phrases/src/locales/ko/translation/demo-app.ts @@ -1,5 +1,4 @@ const demo_app = { - notification: '체험 App에 로그인 하기 위해 관리자 정보 또는 게정을 새로 생성하여 로그인해보세요.', title: '성공적으로 체험 App에 로그인되었어요!', subtitle: '여기 로그인 정보가 있어요:', username: '사용자 이름: ', diff --git a/packages/phrases/src/locales/pt-br/translation/demo-app.ts b/packages/phrases/src/locales/pt-br/translation/demo-app.ts index 48cca8d14..3a10dc3dd 100644 --- a/packages/phrases/src/locales/pt-br/translation/demo-app.ts +++ b/packages/phrases/src/locales/pt-br/translation/demo-app.ts @@ -1,6 +1,4 @@ const demo_app = { - notification: - 'Use sua conta de administrador padrão ou crie uma nova conta para entrar no aplicativo de demonstração.', title: 'Você se inscreveu com sucesso no aplicativo de demonstração!', subtitle: 'Aqui estão suas informações de login:', username: 'Nome de usuário: ', diff --git a/packages/phrases/src/locales/pt-pt/translation/demo-app.ts b/packages/phrases/src/locales/pt-pt/translation/demo-app.ts index ee7cc19d3..c8b849099 100644 --- a/packages/phrases/src/locales/pt-pt/translation/demo-app.ts +++ b/packages/phrases/src/locales/pt-pt/translation/demo-app.ts @@ -1,6 +1,4 @@ const demo_app = { - notification: - 'Use a sua conta de administrador padrão ou crie uma nova conta para entrar na app de demonstração.', title: 'Entrou com sucesso na app de demonstração!', subtitle: 'Aqui estão as suas informações de login:', username: 'Utilizador: ', diff --git a/packages/phrases/src/locales/tr-tr/translation/demo-app.ts b/packages/phrases/src/locales/tr-tr/translation/demo-app.ts index 9ff15234e..58f60a670 100644 --- a/packages/phrases/src/locales/tr-tr/translation/demo-app.ts +++ b/packages/phrases/src/locales/tr-tr/translation/demo-app.ts @@ -1,5 +1,4 @@ const demo_app = { - notification: 'Demo uygulamaya giriş yapmak için yönetici konsolunu kullanınız.', title: 'Demo uygulamaya başarıyla giriş yaptınız!', subtitle: 'Sisteme giriş bilgileriniz:', username: 'Kullanıcı Adı: ', diff --git a/packages/phrases/src/locales/zh-cn/translation/demo-app.ts b/packages/phrases/src/locales/zh-cn/translation/demo-app.ts index 92d4c6552..ba219d9e6 100644 --- a/packages/phrases/src/locales/zh-cn/translation/demo-app.ts +++ b/packages/phrases/src/locales/zh-cn/translation/demo-app.ts @@ -1,6 +1,4 @@ const demo_app = { - notification: - '管理控制台的用户名和密码是 demo app 的默认登录方式。点击下方创建账号或用现有账号登录。', title: '恭喜!你已成功登录到示例应用!', subtitle: '以下是本次登录的用户信息:', username: '用户名:', diff --git a/packages/schemas/alterations/next-1676906977-remove-demo-app.ts b/packages/schemas/alterations/next-1676906977-remove-demo-app.ts index 39573e24c..f88838c52 100644 --- a/packages/schemas/alterations/next-1676906977-remove-demo-app.ts +++ b/packages/schemas/alterations/next-1676906977-remove-demo-app.ts @@ -5,8 +5,6 @@ import { sql } from 'slonik'; import type { AlterationScript } from '../lib/types/alteration.js'; -const defaultTenantId = 'default'; - const alteration: AlterationScript = { up: async (pool) => { const isCi = process.env.CI; @@ -44,7 +42,7 @@ const alteration: AlterationScript = { 'Logto demo app.', 'SPA', '{ "redirectUris": [], "postLogoutRedirectUris": [] }'::jsonb - ) + ); `); }, }; diff --git a/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts b/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts new file mode 100644 index 000000000..829615d09 --- /dev/null +++ b/packages/schemas/alterations/next-1676956206-move-console-sie-to-database.ts @@ -0,0 +1,86 @@ +import { generateDarkColor } from '@logto/core-kit'; +import { sql } from 'slonik'; + +import type { AlterationScript } from '../lib/types/alteration.js'; + +const defaultPrimaryColor = '#6139F6'; + +const data = { + tenantId: 'admin', + id: 'default', + color: { + primaryColor: defaultPrimaryColor, + isDarkModeEnabled: true, + darkPrimaryColor: generateDarkColor(defaultPrimaryColor), + }, + branding: { + style: 'Logo_Slogan', + logoUrl: 'https://logto.io/logo.svg', + darkLogoUrl: 'https://logto.io/logo-dark.svg', + slogan: 'admin_console.welcome.title', + }, + languageInfo: { + autoDetect: true, + fallbackLanguage: 'en', + }, + termsOfUseUrl: null, + signUp: { + identifiers: ['username'], + password: true, + verify: false, + }, + signIn: { + methods: [ + { + identifier: 'username', + password: true, + verificationCode: false, + isPasswordPrimary: true, + }, + ], + }, + socialSignInConnectorTargets: [], + signInMode: 'Register', + customCss: null, +}; + +const alteration: AlterationScript = { + up: async (pool) => { + await pool.query(sql` + insert into sign_in_experiences ( + tenant_id, + id, + color, + branding, + language_info, + terms_of_use_url, + sign_up, + sign_in, + social_sign_in_connector_targets, + sign_in_mode, + custom_css + ) values ( + ${data.tenantId}, + ${data.id}, + ${sql.jsonb(data.color)}, + ${sql.jsonb(data.branding)}, + ${sql.jsonb(data.languageInfo)}, + ${data.termsOfUseUrl}, + ${sql.jsonb(data.signUp)}, + ${sql.jsonb(data.signIn)}, + ${sql.jsonb(data.socialSignInConnectorTargets)}, + ${data.signInMode}, + ${data.customCss} + ); + `); + }, + down: async (pool) => { + await pool.query(sql` + delete from sign_in_experiences + where tenant_id = 'admin' + and id = 'default'; + `); + }, +}; + +export default alteration; diff --git a/packages/schemas/src/seeds/sign-in-experience.ts b/packages/schemas/src/seeds/sign-in-experience.ts index 96bd6f0b3..2e7ba4136 100644 --- a/packages/schemas/src/seeds/sign-in-experience.ts +++ b/packages/schemas/src/seeds/sign-in-experience.ts @@ -3,7 +3,7 @@ import { generateDarkColor } from '@logto/core-kit'; import type { SignInExperience } from '../db-entries/index.js'; import { SignInMode } from '../db-entries/index.js'; import { BrandingStyle, SignInIdentifier } from '../foundations/index.js'; -import { defaultTenantId } from './tenant.js'; +import { adminTenantId, defaultTenantId } from './tenant.js'; const defaultPrimaryColor = '#6139F6'; @@ -49,16 +49,19 @@ export const createDefaultSignInExperience = (forTenantId: string): Readonly = Object.freeze({ - ...defaultSignInExperience, - color: { - ...defaultSignInExperience.color, - isDarkModeEnabled: true, - }, - branding: { - style: BrandingStyle.Logo_Slogan, - logoUrl: 'https://logto.io/logo.svg', - darkLogoUrl: 'https://logto.io/logo-dark.svg', - slogan: 'admin_console.welcome.title', // TODO: @simeng should we programmatically support an i18n key for slogan? - }, -}); +export const createAdminTenantSignInExperience = (): Readonly => + Object.freeze({ + ...defaultSignInExperience, + tenantId: adminTenantId, + color: { + ...defaultSignInExperience.color, + isDarkModeEnabled: true, + }, + signInMode: SignInMode.Register, + branding: { + style: BrandingStyle.Logo_Slogan, + logoUrl: 'https://logto.io/logo.svg', + darkLogoUrl: 'https://logto.io/logo-dark.svg', + slogan: 'admin_console.welcome.title', + }, + }); diff --git a/packages/ui/src/components/Notification/AppNotification/index.tsx b/packages/ui/src/components/Notification/AppNotification/index.tsx index c51e0fc51..15ea6e1dc 100644 --- a/packages/ui/src/components/Notification/AppNotification/index.tsx +++ b/packages/ui/src/components/Notification/AppNotification/index.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import type { CSSProperties, ForwardedRef } from 'react'; +import type { CSSProperties, ForwardedRef, ReactNode } from 'react'; import { forwardRef } from 'react'; import InfoIcon from '@/assets/icons/info-icon.svg'; @@ -10,7 +10,7 @@ import * as styles from './index.module.scss'; /* eslint-disable react/require-default-props */ type Props = { className?: string; - message: string; + message: ReactNode; onClose: () => void; style?: CSSProperties; }; diff --git a/packages/ui/src/containers/AppNotification/index.tsx b/packages/ui/src/containers/AppNotification/index.tsx index fabdab482..8a78bedb5 100644 --- a/packages/ui/src/containers/AppNotification/index.tsx +++ b/packages/ui/src/containers/AppNotification/index.tsx @@ -1,24 +1,19 @@ -import { useContext, useState, useEffect, useCallback, useRef } from 'react'; +import type { Nullable } from '@silverhand/essentials'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { createPortal } from 'react-dom'; +import { Trans, useTranslation } from 'react-i18next'; import { AppNotification as Notification } from '@/components/Notification'; -import { PageContext } from '@/hooks/use-page-context'; import usePlatform from '@/hooks/use-platform'; import * as styles from './index.module.scss'; const AppNotification = () => { const { isMobile } = usePlatform(); - const { experienceSettings } = useContext(PageContext); - const [notification, setNotification] = useState(); + const [notification, setNotification] = useState>(null); const [topOffset, setTopOffset] = useState(); const eleRef = useRef(null); - - useEffect(() => { - if (experienceSettings?.notification) { - setNotification(experienceSettings.notification); - } - }, [experienceSettings]); + const { t } = useTranslation(); const adjustNotificationPosition = useCallback(() => { const mainEleOffsetTop = document.querySelector('main')?.offsetTop; @@ -30,6 +25,10 @@ const AppNotification = () => { } }, []); + useEffect(() => { + setNotification(new URLSearchParams(window.location.search).get('notification')); + }, []); + useEffect(() => { if (!notification || isMobile) { return; @@ -45,7 +44,7 @@ const AppNotification = () => { }, [adjustNotificationPosition, isMobile, notification]); const onClose = useCallback(() => { - setNotification(''); + setNotification(null); }, []); if (!notification) { @@ -56,7 +55,7 @@ const AppNotification = () => { {notification}} style={isMobile ? undefined : { top: topOffset }} onClose={onClose} />,