diff --git a/.changeset-staged/stupid-jokes-brush.md b/.changeset-staged/stupid-jokes-brush.md new file mode 100644 index 000000000..a8004a6fc --- /dev/null +++ b/.changeset-staged/stupid-jokes-brush.md @@ -0,0 +1,6 @@ +--- +"@logto/core": major +"@logto/ui": major +--- + +**💥 BREAKING CHANGE 💥** Move `/api/phrase` API to `/api/.well-known/phrases` diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index 08319f2eb..91f1270fc 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -19,7 +19,6 @@ import hookRoutes from './hook.js'; import interactionRoutes from './interaction/index.js'; import logRoutes from './log.js'; import logtoConfigRoutes from './logto-config.js'; -import phraseRoutes from './phrase.js'; import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; @@ -54,7 +53,6 @@ const createRouters = (tenant: TenantContext) => { userAssetsRoutes(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); - phraseRoutes(anonymousRouter, tenant); wellKnownRoutes(anonymousRouter, tenant); statusRoutes(anonymousRouter, tenant); authnRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/phrase.ts b/packages/core/src/routes/phrase.ts deleted file mode 100644 index 1ef261636..000000000 --- a/packages/core/src/routes/phrase.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isBuiltInLanguageTag } from '@logto/phrases-ui'; -import { object, string } from 'zod'; - -import detectLanguage from '#src/i18n/detect-language.js'; -import koaGuard from '#src/middleware/koa-guard.js'; - -import type { AnonymousRouter, RouterInitArgs } from './types.js'; - -export default function phraseRoutes( - ...[router, { queries, libraries }]: RouterInitArgs -) { - const { - customPhrases: { findAllCustomLanguageTags }, - signInExperiences: { findDefaultSignInExperience }, - } = queries; - const { getPhrases } = libraries.phrases; - - const getLanguageInfo = async () => { - const { languageInfo } = await findDefaultSignInExperience(); - - return languageInfo; - }; - - router.get( - '/phrase', - koaGuard({ - query: object({ - lng: string().optional(), - }), - }), - async (ctx, next) => { - const { - query: { lng }, - } = ctx.guard; - - const { autoDetect, fallbackLanguage } = await getLanguageInfo(); - - const targetLanguage = lng ? [lng] : []; - const detectedLanguages = autoDetect ? detectLanguage(ctx) : []; - const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage]; - const customLanguages = await findAllCustomLanguageTags(); - const language = - acceptableLanguages.find( - (tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag) - ) ?? 'en'; - - ctx.set('Content-Language', language); - ctx.body = await getPhrases(language, customLanguages); - - return next(); - } - ); -} diff --git a/packages/core/src/routes/phrase.content-language.test.ts b/packages/core/src/routes/well-known.phrases.content-language.test.ts similarity index 91% rename from packages/core/src/routes/phrase.content-language.test.ts rename to packages/core/src/routes/well-known.phrases.content-language.test.ts index 078eef7b3..f09b21f79 100644 --- a/packages/core/src/routes/phrase.content-language.test.ts +++ b/packages/core/src/routes/well-known.phrases.content-language.test.ts @@ -32,7 +32,7 @@ const tenantContext = new MockTenant( { phrases: { getPhrases: jest.fn().mockResolvedValue(en) } } ); -const phraseRoutes = await pickDefault(import('./phrase.js')); +const phraseRoutes = await pickDefault(import('./well-known.js')); const phraseRequest = createRequester({ anonymousRoutes: phraseRoutes, @@ -54,7 +54,7 @@ describe('when auto-detect is not enabled', () => { }, }); const response = await phraseRequest - .get('/phrase') + .get('/.well-known/phrases') .set('Accept-Language', `${zhCnTag},${zhHkTag}`); expect(response.headers['content-language']).toEqual('en'); }); @@ -71,13 +71,13 @@ describe('when auto-detect is not enabled', () => { }); it('when there is no detected language', async () => { - const response = await phraseRequest.get('/phrase'); + const response = await phraseRequest.get('/.well-known/phrases'); expect(response.headers['content-language']).toEqual(fallbackLanguage); }); it('when there are detected languages', async () => { const response = await phraseRequest - .get('/phrase') + .get('/.well-known/phrases') .set('Accept-Language', `${zhCnTag},${zhHkTag}`); expect(response.headers['content-language']).toEqual(fallbackLanguage); }); @@ -95,7 +95,7 @@ describe('when auto-detect is enabled', () => { }, }); const response = await phraseRequest - .get('/phrase') + .get('/.well-known/phrases') .set('Accept-Language', unsupportedLanguageY); expect(response.headers['content-language']).toEqual('en'); }); @@ -113,7 +113,7 @@ describe('when auto-detect is enabled', () => { describe('when there is no detected language', () => { it('should be fallback language from sign-in experience', async () => { - const response = await phraseRequest.get('/phrase'); + const response = await phraseRequest.get('/.well-known/phrases'); expect(response.headers['content-language']).toEqual(fallbackLanguage); }); }); @@ -121,7 +121,7 @@ describe('when auto-detect is enabled', () => { describe('when there are detected languages but all of them is unsupported', () => { it('should be first supported detected language', async () => { const response = await phraseRequest - .get('/phrase') + .get('/.well-known/phrases') .set('Accept-Language', `${unsupportedLanguageX},${unsupportedLanguageY}`); expect(response.headers['content-language']).toEqual(fallbackLanguage); }); @@ -131,7 +131,7 @@ describe('when auto-detect is enabled', () => { it('should be first supported detected language', async () => { const firstSupportedLanguage = zhCnTag; const response = await phraseRequest - .get('/phrase') + .get('/.well-known/phrases') .set('Accept-Language', `${unsupportedLanguageX},${firstSupportedLanguage},${zhHkTag}`); expect(response.headers['content-language']).toEqual(firstSupportedLanguage); }); diff --git a/packages/core/src/routes/phrase.test.ts b/packages/core/src/routes/well-known.phrases.test.ts similarity index 83% rename from packages/core/src/routes/phrase.test.ts rename to packages/core/src/routes/well-known.phrases.test.ts index a69953ea9..eb43a0fc2 100644 --- a/packages/core/src/routes/phrase.test.ts +++ b/packages/core/src/routes/well-known.phrases.test.ts @@ -49,7 +49,7 @@ const tenantContext = new MockTenant( { phrases: { getPhrases } } ); -const phraseRoutes = await pickDefault(import('./phrase.js')); +const phraseRoutes = await pickDefault(import('./well-known.js')); const { createRequester } = await import('#src/utils/test-utils.js'); const phraseRequest = createRequester({ @@ -63,7 +63,7 @@ describe('when the application is not admin-console', () => { }); it('should call findDefaultSignInExperience', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); + await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200); expect(findDefaultSignInExperience).toBeCalledTimes(1); }); @@ -75,7 +75,7 @@ describe('when the application is not admin-console', () => { autoDetect: true, }, }); - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); + await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200); expect(detectLanguageSpy).toBeCalledTimes(1); }); @@ -87,12 +87,12 @@ describe('when the application is not admin-console', () => { autoDetect: false, }, }); - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); + await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200); expect(detectLanguageSpy).not.toBeCalled(); }); it('should call findAllCustomLanguageTags', async () => { - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); + await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200); expect(findAllCustomLanguageTags).toBeCalledTimes(1); }); @@ -104,7 +104,7 @@ describe('when the application is not admin-console', () => { fallbackLanguage: customizedLanguage, }, }); - await expect(phraseRequest.get('/phrase')).resolves.toHaveProperty('status', 200); + await expect(phraseRequest.get('/.well-known/phrases')).resolves.toHaveProperty('status', 200); expect(getPhrases).toBeCalledTimes(1); expect(getPhrases).toBeCalledWith(customizedLanguage, [customizedLanguage]); }); @@ -117,7 +117,10 @@ describe('when the application is not admin-console', () => { fallbackLanguage: customizedLanguage, }, }); - await expect(phraseRequest.get('/phrase?lng=fr')).resolves.toHaveProperty('status', 200); + await expect(phraseRequest.get('/.well-known/phrases?lng=fr')).resolves.toHaveProperty( + 'status', + 200 + ); expect(getPhrases).toBeCalledWith('fr', [customizedLanguage]); }); }); diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 61bd5724f..463355608 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -1,18 +1,27 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorType } from '@logto/connector-kit'; +import { isBuiltInLanguageTag } from '@logto/phrases-ui'; import { adminTenantId } from '@logto/schemas'; +import { object, string } from 'zod'; import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js'; import RequestError from '#src/errors/RequestError/index.js'; +import detectLanguage from '#src/i18n/detect-language.js'; +import koaGuard from '#src/middleware/koa-guard.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; export default function wellKnownRoutes( - ...[router, { libraries, id }]: RouterInitArgs + ...[router, { queries, libraries, id }]: RouterInitArgs ) { + const { + customPhrases: { findAllCustomLanguageTags }, + signInExperiences: { findDefaultSignInExperience }, + } = queries; const { signInExperiences: { getSignInExperience }, connectors: { getLogtoConnectors }, + phrases: { getPhrases }, } = libraries; if (id === adminTenantId) { @@ -61,4 +70,36 @@ export default function wellKnownRoutes( return next(); }); + + router.get( + '/.well-known/phrases', + koaGuard({ + query: object({ + lng: string().optional(), + }), + }), + async (ctx, next) => { + const { + query: { lng }, + } = ctx.guard; + + const { + languageInfo: { autoDetect, fallbackLanguage }, + } = await findDefaultSignInExperience(); + + const targetLanguage = lng ? [lng] : []; + const detectedLanguages = autoDetect ? detectLanguage(ctx) : []; + const acceptableLanguages = [...targetLanguage, ...detectedLanguages, fallbackLanguage]; + const customLanguages = await findAllCustomLanguageTags(); + const language = + acceptableLanguages.find( + (tag) => isBuiltInLanguageTag(tag) || customLanguages.includes(tag) + ) ?? 'en'; + + ctx.set('Content-Language', language); + ctx.body = await getPhrases(language, customLanguages); + + return next(); + } + ); } diff --git a/packages/ui/src/apis/settings.ts b/packages/ui/src/apis/settings.ts index 8d452f75c..074608b94 100644 --- a/packages/ui/src/apis/settings.ts +++ b/packages/ui/src/apis/settings.ts @@ -3,6 +3,7 @@ * The API will be deprecated in the future once SSR is implemented. */ +import { conditionalString } from '@silverhand/essentials'; import ky from 'ky'; import type { SignInExperienceResponse } from '@/types'; @@ -30,4 +31,4 @@ export const getPhrases = async ({ ], }, }) - .get(`/api/phrase${language ? `?lng=${language}` : ''}`); + .get(`/api/.well-known/phrases${conditionalString(language && `?lng=${language}`)}`); diff --git a/packages/ui/src/index.html b/packages/ui/src/index.html index acec71900..6257ec934 100644 --- a/packages/ui/src/index.html +++ b/packages/ui/src/index.html @@ -6,7 +6,7 @@ Logto - +