0
Fork 0
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:
Gao Sun 2023-02-21 21:24:43 +08:00 committed by GitHub
parent 14fe40e846
commit 6858f3c8dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
44 changed files with 258 additions and 323 deletions

View file

@ -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

View file

@ -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),
]);
};

View file

@ -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;
};

View file

@ -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,
};
};

View file

@ -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', () => {

View file

@ -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': {

View file

@ -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.

View file

@ -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 = {

View file

@ -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 });

View file

@ -117,7 +117,7 @@ const tenantContext = new MockTenant(
},
},
signInExperiences: {
getSignInExperienceForApplication: jest.fn().mockResolvedValue(mockSignInExperience),
getSignInExperience: jest.fn().mockResolvedValue(mockSignInExperience),
},
}
);

View file

@ -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;

View file

@ -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);

View file

@ -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) : [];

View file

@ -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,
});
});
});

View file

@ -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,

View file

@ -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',

View 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' },
});

View file

@ -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';

View file

@ -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>();

View file

@ -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')
);

View 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' });
});
});

View file

@ -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' });
});
});

View file

@ -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({

View file

@ -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({

View file

@ -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({

View file

@ -106,6 +106,10 @@ const translation = {
unknown: '알 수 없는 오류가 발생했어요. 잠시 후에 시도해 주세요.',
invalid_session: '세션을 찾을 수 없어요. 다시 로그인해 주세요.',
},
demo_app: {
notification:
'체험 App에 로그인 하기 위해 관리자 정보 또는 게정을 새로 생성하여 로그인해보세요.',
},
};
const ko: LocalePhrase = Object.freeze({

View file

@ -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({

View file

@ -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({

View file

@ -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({

View file

@ -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({

View file

@ -104,6 +104,10 @@ const translation = {
unknown: '未知错误,请稍后重试。',
invalid_session: '未找到会话,请返回并重新登录。',
},
demo_app: {
notification:
'管理控制台的用户名和密码是 demo app 的默认登录方式。点击下方创建账号或用现有账号登录。',
},
};
const zhCN: LocalePhrase = Object.freeze({

View file

@ -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: ',

View file

@ -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: ',

View file

@ -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 : ",

View file

@ -1,5 +1,4 @@
const demo_app = {
notification: '체험 App에 로그인 하기 위해 관리자 정보 또는 게정을 새로 생성하여 로그인해보세요.',
title: '성공적으로 체험 App에 로그인되었어요!',
subtitle: '여기 로그인 정보가 있어요:',
username: '사용자 이름: ',

View file

@ -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: ',

View file

@ -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: ',

View file

@ -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ı: ',

View file

@ -1,6 +1,4 @@
const demo_app = {
notification:
'管理控制台的用户名和密码是 demo app 的默认登录方式。点击下方创建账号或用现有账号登录。',
title: '恭喜!你已成功登录到示例应用!',
subtitle: '以下是本次登录的用户信息:',
username: '用户名:',

View file

@ -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
)
);
`);
},
};

View file

@ -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;

View file

@ -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',
},
});

View file

@ -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;
};

View file

@ -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}
/>,