mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
refactor: move console sie to database (#3171)
This commit is contained in:
parent
14fe40e846
commit
6858f3c8dc
44 changed files with 258 additions and 323 deletions
1
.github/workflows/integration-test.yml
vendored
1
.github/workflows/integration-test.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
]);
|
||||
};
|
||||
|
|
|
@ -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<string | undefined> => {
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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<SignInExperience & { notification?: string }> => {
|
||||
// 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<SignInExperience> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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': {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -117,7 +117,7 @@ const tenantContext = new MockTenant(
|
|||
},
|
||||
},
|
||||
signInExperiences: {
|
||||
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||
getSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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<ContextT> = WithInteractionDetailsContext<
|
|||
};
|
||||
|
||||
export default function koaInteractionSie<StateT, ContextT, ResponseT>({
|
||||
getSignInExperienceForApplication,
|
||||
getSignInExperience,
|
||||
}: SignInExperienceLibrary): MiddlewareType<
|
||||
StateT,
|
||||
WithInteractionSieContext<ContextT>,
|
||||
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;
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<T extends AnonymousRouter>(
|
||||
...[router, { provider, queries, libraries }]: RouterInitArgs<T>
|
||||
...[router, { queries, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
customPhrases: { findAllCustomLanguageTags },
|
||||
|
@ -16,11 +15,7 @@ export default function phraseRoutes<T extends AnonymousRouter>(
|
|||
} = 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<T extends AnonymousRouter>(
|
|||
}),
|
||||
}),
|
||||
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) : [];
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<T extends AnonymousRouter>(
|
||||
...[router, { provider, libraries, id }]: RouterInitArgs<T>
|
||||
...[router, { libraries, id }]: RouterInitArgs<T>
|
||||
) {
|
||||
const {
|
||||
signInExperiences: { getSignInExperienceForApplication },
|
||||
signInExperiences: { getSignInExperience },
|
||||
connectors: { getLogtoConnectors },
|
||||
} = libraries;
|
||||
|
||||
|
@ -34,10 +33,8 @@ export default function wellKnownRoutes<T extends AnonymousRouter>(
|
|||
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<T extends AnonymousRouter>(
|
|||
email: logtoConnectors.some(({ type }) => type === ConnectorType.Email),
|
||||
};
|
||||
|
||||
const socialConnectors =
|
||||
applicationId === adminConsoleApplicationId
|
||||
? []
|
||||
: signInExperience.socialSignInConnectorTargets.reduce<
|
||||
Array<ConnectorMetadata & { id: string }>
|
||||
>((previous, connectorTarget) => {
|
||||
const connectors = logtoConnectors.filter(
|
||||
({ metadata: { target } }) => target === connectorTarget
|
||||
);
|
||||
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
|
||||
Array<ConnectorMetadata & { id: string }>
|
||||
>((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,
|
||||
|
|
|
@ -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)?$': '<rootDir>/lib/$1',
|
||||
|
|
7
packages/integration-tests/jest.setup.api.js
Normal file
7
packages/integration-tests/jest.setup.api.js
Normal file
|
@ -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' },
|
||||
});
|
|
@ -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';
|
||||
|
|
|
@ -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<SignInExperience>();
|
|
@ -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')
|
||||
);
|
||||
|
||||
|
|
36
packages/integration-tests/src/tests/api/well-known.test.ts
Normal file
36
packages/integration-tests/src/tests/api/well-known.test.ts
Normal file
|
@ -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<SignInExperience>();
|
||||
|
||||
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<SignInExperience>();
|
||||
|
||||
// Should support sign-in and register
|
||||
expect(response).toMatchObject({ signInMode: 'SignInAndRegister' });
|
||||
});
|
||||
});
|
|
@ -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' });
|
||||
});
|
||||
});
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -106,6 +106,10 @@ const translation = {
|
|||
unknown: '알 수 없는 오류가 발생했어요. 잠시 후에 시도해 주세요.',
|
||||
invalid_session: '세션을 찾을 수 없어요. 다시 로그인해 주세요.',
|
||||
},
|
||||
demo_app: {
|
||||
notification:
|
||||
'체험 App에 로그인 하기 위해 관리자 정보 또는 게정을 새로 생성하여 로그인해보세요.',
|
||||
},
|
||||
};
|
||||
|
||||
const ko: LocalePhrase = Object.freeze({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -104,6 +104,10 @@ const translation = {
|
|||
unknown: '未知错误,请稍后重试。',
|
||||
invalid_session: '未找到会话,请返回并重新登录。',
|
||||
},
|
||||
demo_app: {
|
||||
notification:
|
||||
'管理控制台的用户名和密码是 demo app 的默认登录方式。点击下方创建账号或用现有账号登录。',
|
||||
},
|
||||
};
|
||||
|
||||
const zhCN: LocalePhrase = Object.freeze({
|
||||
|
|
|
@ -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: ',
|
||||
|
|
|
@ -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: ',
|
||||
|
|
|
@ -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 : ",
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
const demo_app = {
|
||||
notification: '체험 App에 로그인 하기 위해 관리자 정보 또는 게정을 새로 생성하여 로그인해보세요.',
|
||||
title: '성공적으로 체험 App에 로그인되었어요!',
|
||||
subtitle: '여기 로그인 정보가 있어요:',
|
||||
username: '사용자 이름: ',
|
||||
|
|
|
@ -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: ',
|
||||
|
|
|
@ -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: ',
|
||||
|
|
|
@ -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ı: ',
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
const demo_app = {
|
||||
notification:
|
||||
'管理控制台的用户名和密码是 demo app 的默认登录方式。点击下方创建账号或用现有账号登录。',
|
||||
title: '恭喜!你已成功登录到示例应用!',
|
||||
subtitle: '以下是本次登录的用户信息:',
|
||||
username: '用户名:',
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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;
|
|
@ -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<Sig
|
|||
/** @deprecated Use `createDefaultSignInExperience()` instead. */
|
||||
export const defaultSignInExperience = createDefaultSignInExperience(defaultTenantId);
|
||||
|
||||
export const adminConsoleSignInExperience: Readonly<SignInExperience> = 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<SignInExperience> =>
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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<string>();
|
||||
const [notification, setNotification] = useState<Nullable<string>>(null);
|
||||
const [topOffset, setTopOffset] = useState<number>();
|
||||
const eleRef = useRef<HTMLDivElement>(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
|
||||
ref={eleRef}
|
||||
className={styles.appNotification}
|
||||
message={notification}
|
||||
message={<Trans t={t}>{notification}</Trans>}
|
||||
style={isMobile ? undefined : { top: topOffset }}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
|
|
Loading…
Reference in a new issue