0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

refactor!: merge settings into logto configs table

This commit is contained in:
Gao Sun 2023-01-29 19:42:19 +08:00
parent eec39f7d9a
commit d0399eb8a4
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
34 changed files with 440 additions and 354 deletions

View file

@ -8,7 +8,7 @@ import { createPoolFromConfig } from '../../../database.js';
import {
getCurrentDatabaseAlterationTimestamp,
updateDatabaseTimestamp,
} from '../../../queries/logto-config.js';
} from '../../../queries/system.js';
import { log } from '../../../utilities.js';
import type { AlterationFile } from './type.js';
import { getAlterationFiles, getTimestampFromFilename } from './utils.js';

View file

@ -7,7 +7,7 @@ import {
LogtoOidcConfigKey,
managementResource,
defaultSignInExperience,
createDefaultSetting,
createDefaultAdminConsoleConfig,
createDemoAppApplication,
defaultRole,
managementResourceScope,
@ -26,9 +26,9 @@ import { createPoolAndDatabaseIfNeeded, insertInto } from '../../../database.js'
import {
getRowsByKeys,
doesConfigsTableExist,
updateDatabaseTimestamp,
updateValueByKey,
} from '../../../queries/logto-config.js';
import { updateDatabaseTimestamp } from '../../../queries/system.js';
import { getPathInModule, log, oraPromise } from '../../../utilities.js';
import { getLatestAlterationTimestamp } from '../alteration/index.js';
import { getAlterationDirectory } from '../alteration/utils.js';
@ -89,7 +89,7 @@ const seedTables = async (connection: DatabaseTransactionConnection, latestTimes
await Promise.all([
connection.query(insertInto(managementResource, 'resources')),
connection.query(insertInto(managementResourceScope, 'scopes')),
connection.query(insertInto(createDefaultSetting(), 'settings')),
connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),
connection.query(insertInto(defaultSignInExperience, 'sign_in_experiences')),
connection.query(insertInto(createDemoAppApplication(generateStandardId()), 'applications')),
connection.query(insertInto(defaultRole, 'roles')),

View file

@ -1,10 +1,10 @@
import type { AlterationState, LogtoConfig, LogtoConfigKey } from '@logto/schemas';
import { logtoConfigGuards, LogtoConfigs, AlterationStateKey } from '@logto/schemas';
import type { LogtoConfig, LogtoConfigKey, logtoConfigGuards } from '@logto/schemas';
import { LogtoConfigs } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { Nullable } from '@silverhand/essentials';
import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import { z } from 'zod';
import type { z } from 'zod';
const { table, fields } = convertToIdentifiers(LogtoConfigs);
@ -34,36 +34,3 @@ export const updateValueByKey = async <T extends LogtoConfigKey>(
on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value}
`
);
export const getCurrentDatabaseAlterationTimestamp = async (pool: CommonQueryMethods) => {
try {
const result = await pool.maybeOne<LogtoConfig>(
sql`select * from ${table} where ${fields.key}=${AlterationStateKey.AlterationState}`
);
const parsed = logtoConfigGuards[AlterationStateKey.AlterationState].safeParse(result?.value);
return (parsed.success && parsed.data.timestamp) || 0;
} catch (error: unknown) {
const result = z.object({ code: z.string() }).safeParse(error);
// Relation does not exist, treat as 0
// https://www.postgresql.org/docs/14/errcodes-appendix.html
if (result.success && result.data.code === '42P01') {
return 0;
}
throw error;
}
};
export const updateDatabaseTimestamp = async (
connection: DatabaseTransactionConnection,
timestamp: number
) => {
const value: AlterationState = {
timestamp,
updatedAt: new Date().toISOString(),
};
return updateValueByKey(connection, AlterationStateKey.AlterationState, value);
};

View file

@ -1,10 +1,10 @@
import { AlterationStateKey, LogtoConfigs } from '@logto/schemas';
import { AlterationStateKey, Systems } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import type { QueryType } from '../test-utilities.js';
import { expectSqlAssert } from '../test-utilities.js';
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './logto-config.js';
import { updateDatabaseTimestamp, getCurrentDatabaseAlterationTimestamp } from './system.js';
const { jest } = import.meta;
@ -15,7 +15,7 @@ const pool = createMockPool({
return mockQuery(sql, values);
},
});
const { table, fields } = convertToIdentifiers(LogtoConfigs);
const { table, fields } = convertToIdentifiers(Systems);
const timestamp = 1_663_923_776;
describe('getCurrentDatabaseAlterationTimestamp()', () => {

View file

@ -0,0 +1,67 @@
import type { AlterationState, System } from '@logto/schemas';
import { Systems, logtoConfigGuards, AlterationStateKey } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { Nullable } from '@silverhand/essentials';
import type { CommonQueryMethods, DatabaseTransactionConnection } from 'slonik';
import { sql } from 'slonik';
import { z } from 'zod';
const { fields } = convertToIdentifiers(Systems);
const doesTableExist = async (pool: CommonQueryMethods, table: string) => {
const { rows } = await pool.query<{ regclass: Nullable<string> }>(
sql`select to_regclass(${table}) as regclass`
);
return Boolean(rows[0]?.regclass);
};
export const doesSystemsTableExist = async (pool: CommonQueryMethods) =>
doesTableExist(pool, Systems.table);
const getAlterationStateTable = async (pool: CommonQueryMethods) =>
(await doesSystemsTableExist(pool))
? sql.identifier([Systems.table])
: sql.identifier(['_logto_configs']); // Fall back to the old config table
export const getCurrentDatabaseAlterationTimestamp = async (pool: CommonQueryMethods) => {
const table = await getAlterationStateTable(pool);
try {
const result = await pool.maybeOne<System>(
sql`select * from ${table} where ${fields.key}=${AlterationStateKey.AlterationState}`
);
const parsed = logtoConfigGuards[AlterationStateKey.AlterationState].safeParse(result?.value);
return (parsed.success && parsed.data.timestamp) || 0;
} catch (error: unknown) {
const result = z.object({ code: z.string() }).safeParse(error);
// Relation does not exist, treat as 0
// https://www.postgresql.org/docs/14/errcodes-appendix.html
if (result.success && result.data.code === '42P01') {
return 0;
}
throw error;
}
};
export const updateDatabaseTimestamp = async (
connection: DatabaseTransactionConnection,
timestamp: number
) => {
const table = await getAlterationStateTable(connection);
const value: AlterationState = {
timestamp,
updatedAt: new Date().toISOString(),
};
await connection.query(
sql`
insert into ${table} (${fields.key}, ${fields.value})
values (${AlterationStateKey.AlterationState}, ${sql.jsonb(value)})
on conflict (${fields.key}) do update set ${fields.value}=excluded.${fields.value}
`
);
};

View file

@ -7,8 +7,8 @@ import { Outlet, useHref, useLocation, useNavigate } from 'react-router-dom';
import AppError from '@/components/AppError';
import AppLoading from '@/components/AppLoading';
import SessionExpired from '@/components/SessionExpired';
import useConfigs from '@/hooks/use-configs';
import useScroll from '@/hooks/use-scroll';
import useSettings from '@/hooks/use-settings';
import useUserPreferences from '@/hooks/use-user-preferences';
import Sidebar, { getPath } from './components/Sidebar';
@ -20,8 +20,8 @@ const AppContent = () => {
const { isAuthenticated, isLoading: isLogtoLoading, error, signIn } = useLogto();
const href = useHref('/callback');
const { isLoading: isPreferencesLoading } = useUserPreferences();
const { isLoading: isSettingsLoading } = useSettings();
const isLoading = isPreferencesLoading || isSettingsLoading;
const { isLoading: isConfigsLoading } = useConfigs();
const isLoading = isPreferencesLoading || isConfigsLoading;
const location = useLocation();
const navigate = useNavigate();

View file

@ -0,0 +1,36 @@
import { useLogto } from '@logto/react';
import type { AdminConsoleData } from '@logto/schemas';
import useSWR from 'swr';
import type { RequestError } from './use-api';
import useApi from './use-api';
const useConfigs = () => {
const { isAuthenticated, error: authError } = useLogto();
const shouldFetch = isAuthenticated && !authError;
const {
data: configs,
error,
mutate,
} = useSWR<AdminConsoleData, RequestError>(shouldFetch && '/api/configs/admin-console');
const api = useApi();
const updateConfigs = async (json: Partial<AdminConsoleData>) => {
const updatedConfigs = await api
.patch('/api/configs/admin-console', {
json,
})
.json<AdminConsoleData>();
void mutate(updatedConfigs);
};
return {
isLoading: !configs && !error,
configs,
error,
mutate,
updateConfigs,
};
};
export default useConfigs;

View file

@ -1,40 +0,0 @@
import { useLogto } from '@logto/react';
import type { AdminConsoleConfig, Setting } from '@logto/schemas';
import useSWR from 'swr';
import type { RequestError } from './use-api';
import useApi from './use-api';
const useSettings = () => {
const { isAuthenticated, error: authError } = useLogto();
const shouldFetch = isAuthenticated && !authError;
const {
data: settings,
error,
mutate,
} = useSWR<Setting, RequestError>(shouldFetch && '/api/settings');
const api = useApi();
const updateSettings = async (delta: Partial<AdminConsoleConfig>) => {
const updatedSettings = await api
.patch('/api/settings', {
json: {
adminConsole: {
...delta,
},
},
})
.json<Setting>();
void mutate(updatedSettings);
};
return {
isLoading: !settings && !error,
settings: settings?.adminConsole,
error,
mutate,
updateSettings,
};
};
export default useSettings;

View file

@ -11,7 +11,7 @@ import ModalLayout from '@/components/ModalLayout';
import RadioGroup, { Radio } from '@/components/RadioGroup';
import TextInput from '@/components/TextInput';
import useApi from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings';
import useConfigs from '@/hooks/use-configs';
import * as modalStyles from '@/scss/modal.module.scss';
import { applicationTypeI18nKey } from '@/types/applications';
@ -30,7 +30,7 @@ type Props = {
};
const CreateForm = ({ onClose }: Props) => {
const { updateSettings } = useSettings();
const { updateConfigs } = useConfigs();
const [createdApp, setCreatedApp] = useState<Application>();
const [isGetStartedModalOpen, setIsGetStartedModalOpen] = useState(false);
const {
@ -58,7 +58,7 @@ const CreateForm = ({ onClose }: Props) => {
const createdApp = await api.post('/api/applications', { json: data }).json<Application>();
setCreatedApp(createdApp);
setIsGetStartedModalOpen(true);
void updateSettings({ applicationCreated: true });
void updateConfigs({ applicationCreated: true });
});
return (

View file

@ -16,7 +16,7 @@ import IconButton from '@/components/IconButton';
import Markdown from '@/components/Markdown';
import { ConnectorsTabs } from '@/consts/page-tabs';
import useApi from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings';
import useConfigs from '@/hooks/use-configs';
import SenderTester from '@/pages/ConnectorDetails/components/SenderTester';
import { safeParseJson } from '@/utilities/json';
@ -33,7 +33,7 @@ type Props = {
const Guide = ({ connector, onClose }: Props) => {
const api = useApi();
const navigate = useNavigate();
const { updateSettings } = useSettings();
const { updateConfigs } = useConfigs();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { id: connectorId, type: connectorType, name, readme, isStandard } = connector;
const { language } = i18next;
@ -89,7 +89,7 @@ const Guide = ({ connector, onClose }: Props) => {
})
.json<ConnectorResponse>();
await updateSettings({
await updateConfigs({
...conditional(!isSocialConnector && { passwordlessConfigured: true }),
...conditional(isSocialConnector && { socialSignInConfigured: true }),
});

View file

@ -19,8 +19,8 @@ import SocialDark from '@/assets/images/social-dark.svg';
import Social from '@/assets/images/social.svg';
import { ConnectorsTabs } from '@/consts/page-tabs';
import { RequestError } from '@/hooks/use-api';
import useConfigs from '@/hooks/use-configs';
import useDocumentationUrl from '@/hooks/use-documentation-url';
import useSettings from '@/hooks/use-settings';
import { useTheme } from '@/hooks/use-theme';
type GetStartedMetadata = {
@ -36,7 +36,7 @@ type GetStartedMetadata = {
const useGetStartedMetadata = () => {
const documentationUrl = useDocumentationUrl();
const { settings, updateSettings } = useSettings();
const { configs, updateConfigs } = useConfigs();
const theme = useTheme();
const isLightMode = theme === AppearanceMode.LightMode;
const { data: demoApp, error } = useSWR<Application, RequestError>(
@ -63,10 +63,10 @@ const useGetStartedMetadata = () => {
subtitle: 'get_started.card1_subtitle',
icon: isLightMode ? CheckDemo : CheckDemoDark,
buttonText: 'general.check_out',
isComplete: settings?.demoChecked,
isComplete: configs?.demoChecked,
isHidden: hideDemo,
onClick: async () => {
void updateSettings({ demoChecked: true });
void updateConfigs({ demoChecked: true });
window.open('/demo-app', '_blank');
},
},
@ -76,7 +76,7 @@ const useGetStartedMetadata = () => {
subtitle: 'get_started.card2_subtitle',
icon: isLightMode ? CreateApp : CreateAppDark,
buttonText: 'general.create',
isComplete: settings?.applicationCreated,
isComplete: configs?.applicationCreated,
onClick: () => {
navigate('/applications/create');
},
@ -87,7 +87,7 @@ const useGetStartedMetadata = () => {
subtitle: 'get_started.card3_subtitle',
icon: isLightMode ? Customize : CustomizeDark,
buttonText: 'general.customize',
isComplete: settings?.signInExperienceCustomized,
isComplete: configs?.signInExperienceCustomized,
onClick: () => {
navigate('/sign-in-experience');
},
@ -98,7 +98,7 @@ const useGetStartedMetadata = () => {
subtitle: 'get_started.card4_subtitle',
icon: isLightMode ? Passwordless : PasswordlessDark,
buttonText: 'general.set_up',
isComplete: settings?.passwordlessConfigured,
isComplete: configs?.passwordlessConfigured,
onClick: () => {
navigate(`/connectors/${ConnectorsTabs.Passwordless}`);
},
@ -109,7 +109,7 @@ const useGetStartedMetadata = () => {
subtitle: 'get_started.card5_subtitle',
icon: isLightMode ? Social : SocialDark,
buttonText: 'general.add',
isComplete: settings?.socialSignInConfigured,
isComplete: configs?.socialSignInConfigured,
onClick: () => {
navigate(`/connectors/${ConnectorsTabs.Social}`);
},
@ -120,9 +120,9 @@ const useGetStartedMetadata = () => {
subtitle: 'get_started.card6_subtitle',
icon: isLightMode ? FurtherReadings : FurtherReadingsDark,
buttonText: 'general.check_out',
isComplete: settings?.furtherReadingsChecked,
isComplete: configs?.furtherReadingsChecked,
onClick: () => {
void updateSettings({ furtherReadingsChecked: true });
void updateConfigs({ furtherReadingsChecked: true });
window.open(`${documentationUrl}/docs/tutorials/get-started/further-readings/`, '_blank');
},
},
@ -134,13 +134,13 @@ const useGetStartedMetadata = () => {
hideDemo,
isLightMode,
navigate,
settings?.applicationCreated,
settings?.demoChecked,
settings?.furtherReadingsChecked,
settings?.passwordlessConfigured,
settings?.signInExperienceCustomized,
settings?.socialSignInConfigured,
updateSettings,
configs?.applicationCreated,
configs?.demoChecked,
configs?.furtherReadingsChecked,
configs?.passwordlessConfigured,
configs?.signInExperienceCustomized,
configs?.socialSignInConfigured,
updateConfigs,
]);
return {

View file

@ -12,7 +12,7 @@ import CardTitle from '@/components/CardTitle';
import IconButton from '@/components/IconButton';
import Spacer from '@/components/Spacer';
import useApi from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings';
import useConfigs from '@/hooks/use-configs';
import useUserPreferences from '@/hooks/use-user-preferences';
import * as modalStyles from '@/scss/modal.module.scss';
@ -34,7 +34,7 @@ type Props = {
const GuideModal = ({ isOpen, onClose }: Props) => {
const { data } = useSWR<SignInExperience>('/api/sign-in-exp');
const { data: preferences, update: updatePreferences } = useUserPreferences();
const { updateSettings } = useSettings();
const { updateConfigs } = useConfigs();
const methods = useForm<SignInExperienceForm>();
const {
reset,
@ -68,7 +68,7 @@ const GuideModal = ({ isOpen, onClose }: Props) => {
api.patch('/api/sign-in-exp', {
json: signInExperienceParser.toRemoteModel(formData),
}),
updateSettings({ signInExperienceCustomized: true }),
updateConfigs({ signInExperienceCustomized: true }),
]);
onClose();
@ -76,7 +76,7 @@ const GuideModal = ({ isOpen, onClose }: Props) => {
const onSkip = async () => {
setIsLoading(true);
await updateSettings({ signInExperienceCustomized: true });
await updateConfigs({ signInExperienceCustomized: true });
setIsLoading(false);
onClose();
};

View file

@ -15,7 +15,7 @@ import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
import { SignInExperiencePage } from '@/consts/page-tabs';
import type { RequestError } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import useSettings from '@/hooks/use-settings';
import useConfigs from '@/hooks/use-configs';
import useUiLanguages from '@/hooks/use-ui-languages';
import Preview from './components/Preview';
@ -40,7 +40,7 @@ const SignInExperience = () => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { tab } = useParams();
const { data, error, mutate } = useSWR<SignInExperienceType, RequestError>('/api/sign-in-exp');
const { settings, error: settingsError, updateSettings, mutate: mutateSettings } = useSettings();
const { configs, error: configsError, updateConfigs, mutate: mutateConfigs } = useConfigs();
const { error: languageError, isLoading: isLoadingLanguages } = useUiLanguages();
const [dataToCompare, setDataToCompare] = useState<SignInExperienceType>();
@ -79,7 +79,7 @@ const SignInExperience = () => {
.json<SignInExperienceType>();
void mutate(updatedData);
setDataToCompare(undefined);
await updateSettings({ signInExperienceCustomized: true });
await updateConfigs({ signInExperienceCustomized: true });
toast.success(t('general.saved'));
};
@ -100,23 +100,23 @@ const SignInExperience = () => {
await saveData();
});
if ((!settings && !settingsError) || (!data && !error) || isLoadingLanguages) {
if ((!configs && !configsError) || (!data && !error) || isLoadingLanguages) {
return <Skeleton />;
}
if (!settings && settingsError) {
return <div>{settingsError.body?.message ?? settingsError.message}</div>;
if (!configs && configsError) {
return <div>{configsError.body?.message ?? configsError.message}</div>;
}
if (languageError) {
return <div>{languageError.body?.message ?? languageError.message}</div>;
}
if (!settings?.signInExperienceCustomized) {
if (!configs?.signInExperienceCustomized) {
return (
<Welcome
mutate={() => {
void mutateSettings();
void mutateConfigs();
void mutate();
}}
/>

View file

@ -1,5 +1,12 @@
import { VerificationCodeType } from '@logto/connector-kit';
import type { Application, Passcode, Resource, Role, Scope, Setting } from '@logto/schemas';
import type {
AdminConsoleData,
Application,
Passcode,
Resource,
Role,
Scope,
} from '@logto/schemas';
import { ApplicationType } from '@logto/schemas';
export * from './connector.js';
@ -65,17 +72,13 @@ export const mockRole: Role = {
description: 'admin',
};
export const mockSetting: Setting = {
tenantId: 'fake_tenant',
id: 'foo setting',
adminConsole: {
demoChecked: false,
applicationCreated: false,
signInExperienceCustomized: false,
passwordlessConfigured: false,
socialSignInConfigured: false,
furtherReadingsChecked: false,
},
export const mockAdminConsoleData: AdminConsoleData = {
demoChecked: false,
applicationCreated: false,
signInExperienceCustomized: false,
passwordlessConfigured: false,
socialSignInConfigured: false,
furtherReadingsChecked: false,
};
export const mockPasscode: Passcode = {

View file

@ -48,7 +48,7 @@ export const buildUpdateWhereWithPool =
*/
return sql`
${fields[key]}=
coalesce(${fields[key]},'{}'::jsonb)|| ${convertToPrimitiveOrSql(key, value)}
coalesce(${fields[key]},'{}'::jsonb) || ${convertToPrimitiveOrSql(key, value)}
`;
}

View file

@ -0,0 +1,25 @@
import type { AdminConsoleData } from '@logto/schemas';
import { AdminConsoleConfigKey, LogtoConfigs } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
const { table, fields } = convertToIdentifiers(LogtoConfigs);
export const createLogtoConfigQueries = (pool: CommonQueryMethods) => {
const getAdminConsoleConfig = async () =>
pool.one<Record<string, unknown>>(sql`
select ${fields.value} from ${table}
where ${fields.key} = ${AdminConsoleConfigKey.AdminConsole}
`);
const updateAdminConsoleConfig = async (value: Partial<AdminConsoleData>) =>
pool.one<Record<string, unknown>>(sql`
update ${table}
set ${fields.value}=coalesce(${fields.value},'{}'::jsonb) || ${sql.jsonb(value)}
where ${fields.key} = ${AdminConsoleConfigKey.AdminConsole}
returning ${fields.value}
`);
return { getAdminConsoleConfig, updateAdminConsoleConfig };
};

View file

@ -1,64 +0,0 @@
import { Settings } from '@logto/schemas';
import { convertToIdentifiers } from '@logto/shared';
import { createMockPool, createMockQueryResult, sql } from 'slonik';
import { mockSetting } from '#src/__mocks__/index.js';
import type { QueryType } from '#src/utils/test-utils.js';
import { expectSqlAssert } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const mockQuery: jest.MockedFunction<QueryType> = jest.fn();
const pool = createMockPool({
query: async (sql, values) => {
return mockQuery(sql, values);
},
});
const { defaultSettingId, createSettingQueries } = await import('./setting.js');
const { getSetting, updateSetting } = createSettingQueries(pool);
describe('setting query', () => {
const { table, fields } = convertToIdentifiers(Settings);
const databaseValue = { ...mockSetting, adminConsole: JSON.stringify(mockSetting.adminConsole) };
it('getSetting', async () => {
const expectSql = sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
where ${fields.id}=$1
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([defaultSettingId]);
return createMockQueryResult([databaseValue]);
});
await expect(getSetting()).resolves.toEqual(databaseValue);
});
it('updateSetting', async () => {
const { adminConsole } = mockSetting;
const expectSql = sql`
update ${table}
set
${fields.adminConsole}=
coalesce("admin_console",'{}'::jsonb)|| $1
where ${fields.id}=$2
returning *
`;
mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql);
expect(values).toEqual([JSON.stringify(adminConsole), defaultSettingId]);
return createMockQueryResult([databaseValue]);
});
await expect(updateSetting({ adminConsole })).resolves.toEqual(databaseValue);
});
});

View file

@ -1,24 +0,0 @@
import type { Setting, CreateSetting } from '@logto/schemas';
import { Settings } from '@logto/schemas';
import type { OmitAutoSetFields } from '@logto/shared';
import type { CommonQueryMethods } from 'slonik';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildUpdateWhereWithPool } from '#src/database/update-where.js';
export const defaultSettingId = 'default';
export const createSettingQueries = (pool: CommonQueryMethods) => {
const getSetting = async () =>
buildFindEntityByIdWithPool(pool)<CreateSetting, Setting>(Settings)(defaultSettingId);
const updateSetting = async (setting: Partial<OmitAutoSetFields<CreateSetting>>) => {
return buildUpdateWhereWithPool(pool)<CreateSetting, Setting>(Settings, true)({
set: setting,
where: { id: defaultSettingId },
jsonbMode: 'merge',
});
};
return { getSetting, updateSetting };
};

View file

@ -15,12 +15,12 @@ import dashboardRoutes from './dashboard.js';
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';
import samlAssertionHandlerRoutes from './saml-assertion-handler.js';
import settingRoutes from './setting.js';
import signInExperiencesRoutes from './sign-in-experience/index.js';
import statusRoutes from './status.js';
import swaggerRoutes from './swagger.js';
@ -35,7 +35,7 @@ const createRouters = (tenant: TenantContext) => {
const managementRouter: AuthedRouter = new Router();
managementRouter.use(koaAuth(tenant.envSet, managementResourceScope.name));
applicationRoutes(managementRouter, tenant);
settingRoutes(managementRouter, tenant);
logtoConfigRoutes(managementRouter, tenant);
connectorRoutes(managementRouter, tenant);
resourceRoutes(managementRouter, tenant);
signInExperiencesRoutes(managementRouter, tenant);

View file

@ -0,0 +1,39 @@
import type { AdminConsoleData } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockAdminConsoleData } from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const logtoConfigs = {
getAdminConsoleConfig: async () => ({ value: mockAdminConsoleData }),
updateAdminConsoleConfig: async (data: Partial<AdminConsoleData>) => ({
value: {
...mockAdminConsoleData,
...data,
},
}),
};
const settingRoutes = await pickDefault(import('./logto-config.js'));
describe('configs routes', () => {
const roleRequester = createRequester({
authedRoutes: settingRoutes,
tenantContext: new MockTenant(undefined, { logtoConfigs }),
});
it('GET /configs/admin-console', async () => {
const response = await roleRequester.get('/configs/admin-console');
expect(response.status).toEqual(200);
expect(response.body).toEqual(mockAdminConsoleData);
});
it('PATCH /configs/admin-console', async () => {
const demoChecked = !mockAdminConsoleData.demoChecked;
const response = await roleRequester.patch('/configs/admin-console').send({ demoChecked });
expect(response.status).toEqual(200);
expect(response.body).toEqual({ ...mockAdminConsoleData, demoChecked });
});
});

View file

@ -0,0 +1,35 @@
import { adminConsoleDataGuard } from '@logto/schemas';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function logtoConfigRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const { getAdminConsoleConfig, updateAdminConsoleConfig } = queries.logtoConfigs;
router.get(
'/configs/admin-console',
koaGuard({ response: adminConsoleDataGuard, status: 200 }),
async (ctx, next) => {
ctx.body = await getAdminConsoleConfig();
return next();
}
);
router.patch(
'/configs/admin-console',
koaGuard({
body: adminConsoleDataGuard.partial(),
response: adminConsoleDataGuard,
status: 200,
}),
async (ctx, next) => {
ctx.body = await updateAdminConsoleConfig(ctx.guard.body);
return next();
}
);
}

View file

@ -1,44 +0,0 @@
import type { Setting, CreateSetting } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { mockSetting } from '#src/__mocks__/index.js';
import { MockTenant } from '#src/test-utils/tenant.js';
import { createRequester } from '#src/utils/test-utils.js';
const settings = {
getSetting: async (): Promise<Setting> => mockSetting,
updateSetting: async (data: Partial<CreateSetting>): Promise<Setting> => ({
...mockSetting,
...data,
}),
};
const settingRoutes = await pickDefault(import('./setting.js'));
describe('settings routes', () => {
const roleRequester = createRequester({
authedRoutes: settingRoutes,
tenantContext: new MockTenant(undefined, { settings }),
});
it('GET /settings', async () => {
const response = await roleRequester.get('/settings');
expect(response.status).toEqual(200);
const { id, ...rest } = mockSetting;
expect(response.body).toEqual(rest);
});
it('PATCH /settings', async () => {
const { adminConsole } = mockSetting;
const response = await roleRequester.patch('/settings').send({
adminConsole,
});
expect(response.status).toEqual(200);
expect(response.body).toEqual({
tenantId: 'fake_tenant',
adminConsole,
});
});
});

View file

@ -1,32 +0,0 @@
import { Settings } from '@logto/schemas';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function settingRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const { getSetting, updateSetting } = queries.settings;
router.get('/settings', async (ctx, next) => {
const { id, ...rest } = await getSetting();
ctx.body = rest;
return next();
});
router.patch(
'/settings',
koaGuard({
body: Settings.createGuard.omit({ id: true }).deepPartial(),
}),
async (ctx, next) => {
const { body: partialSettings } = ctx.guard;
const { id, ...rest } = await updateSetting(partialSettings);
ctx.body = rest;
return next();
}
);
}

View file

@ -5,13 +5,13 @@ import { createApplicationsRolesQueries } from '#src/queries/applications-roles.
import { createConnectorQueries } from '#src/queries/connector.js';
import { createCustomPhraseQueries } from '#src/queries/custom-phrase.js';
import { createLogQueries } from '#src/queries/log.js';
import { createLogtoConfigQueries } from '#src/queries/logto-config.js';
import { createOidcModelInstanceQueries } from '#src/queries/oidc-model-instance.js';
import { createPasscodeQueries } from '#src/queries/passcode.js';
import { createResourceQueries } from '#src/queries/resource.js';
import { createRolesScopesQueries } from '#src/queries/roles-scopes.js';
import { createRolesQueries } from '#src/queries/roles.js';
import { createScopeQueries } from '#src/queries/scope.js';
import { createSettingQueries } from '#src/queries/setting.js';
import { createSignInExperienceQueries } from '#src/queries/sign-in-experience.js';
import { createUserQueries } from '#src/queries/user.js';
import { createUsersRolesQueries } from '#src/queries/users-roles.js';
@ -27,7 +27,7 @@ export default class Queries {
rolesScopes = createRolesScopesQueries(this.pool);
roles = createRolesQueries(this.pool);
scopes = createScopeQueries(this.pool);
settings = createSettingQueries(this.pool);
logtoConfigs = createLogtoConfigQueries(this.pool);
signInExperiences = createSignInExperienceQueries(this.pool);
users = createUserQueries(this.pool);
usersRoles = createUsersRolesQueries(this.pool);

View file

@ -0,0 +1,62 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
insert into _logto_configs (key, value)
select 'adminConsole', admin_console from settings
where id='default';
`);
await pool.query(sql`
alter table _logto_configs
add column tenant_id varchar(21) not null default 'default'
references tenants (id) on update cascade on delete cascade;
create trigger set_tenant_id before insert on _logto_configs
for each row execute procedure set_tenant_id();
`);
await pool.query(sql`
alter table _logto_configs
alter column tenant_id drop default;
`);
await pool.query(sql`drop table settings cascade;`);
},
down: async (pool) => {
await pool.query(sql`
create table settings (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
admin_console jsonb not null,
primary key (id)
);
create index settings__id
on settings (tenant_id, id);
create trigger set_tenant_id before insert on settings
for each row execute procedure set_tenant_id();
`);
await pool.query(sql`
insert into settings (id, admin_console)
select 'default', value from _logto_configs
where key='adminConsole';
`);
await pool.query(sql`
delete from _logto_configs
where key='adminConsole';
`);
await pool.query(sql`
alter table _logto_configs
drop column tenant_id,
drop trigger set_tenant_id;
`);
},
};
export default alteration;

View file

@ -0,0 +1,39 @@
import { sql } from 'slonik';
import type { AlterationScript } from '../lib/types/alteration.js';
const alteration: AlterationScript = {
up: async (pool) => {
await pool.query(sql`
create table systems (
key varchar(256) not null,
value jsonb not null default '{}'::jsonb,
primary key (key)
);
alter table _logto_configs rename to logto_configs;
`);
await pool.query(sql`
insert into systems (key, value)
select key, value from logto_configs
where key='alterationState';
`);
await pool.query(sql`
delete from logto_configs
where key='alterationState';
`);
},
down: async (pool) => {
await pool.query(sql`
insert into _logto_configs (key, value)
select key, value from systems
where key='alterationState';
drop table systems;
alter table logto_configs rename to _logto_configs;
`);
},
};
export default alteration;

View file

@ -163,18 +163,6 @@ export enum AppearanceMode {
DarkMode = 'dark',
}
export const adminConsoleConfigGuard = z.object({
// Get started challenges
demoChecked: z.boolean(),
applicationCreated: z.boolean(),
signInExperienceCustomized: z.boolean(),
passwordlessConfigured: z.boolean(),
socialSignInConfigured: z.boolean(),
furtherReadingsChecked: z.boolean(),
});
export type AdminConsoleConfig = z.infer<typeof adminConsoleConfigGuard>;
/**
* Phrases
*/

View file

@ -1,6 +1,6 @@
export * from './application.js';
export * from './resource.js';
export * from './setting.js';
export * from './logto-config.js';
export * from './sign-in-experience.js';
export * from './roles.js';
export * from './scope.js';

View file

@ -0,0 +1,22 @@
import { CreateLogtoConfig } from '../db-entries/index.js';
import { AppearanceMode } from '../foundations/index.js';
import type { AdminConsoleData } from '../types/index.js';
import { AdminConsoleConfigKey } from '../types/index.js';
export const createDefaultAdminConsoleConfig = (): Readonly<{
key: AdminConsoleConfigKey;
value: AdminConsoleData;
}> =>
Object.freeze({
key: AdminConsoleConfigKey.AdminConsole,
value: {
language: 'en',
appearanceMode: AppearanceMode.SyncWithSystem,
demoChecked: false,
applicationCreated: false,
signInExperienceCustomized: false,
passwordlessConfigured: false,
socialSignInConfigured: false,
furtherReadingsChecked: false,
},
} satisfies CreateLogtoConfig);

View file

@ -1,21 +0,0 @@
import type { CreateSetting } from '../db-entries/index.js';
import { AppearanceMode } from '../foundations/index.js';
import { defaultTenantId } from './tenant.js';
export const defaultSettingId = 'default';
export const createDefaultSetting = (): Readonly<CreateSetting> =>
Object.freeze({
tenantId: defaultTenantId,
id: defaultSettingId,
adminConsole: {
language: 'en',
appearanceMode: AppearanceMode.SyncWithSystem,
demoChecked: false,
applicationCreated: false,
signInExperienceCustomized: false,
passwordlessConfigured: false,
socialSignInConfigured: false,
furtherReadingsChecked: false,
},
});

View file

@ -39,17 +39,48 @@ export const logtoOidcConfigGuard: Readonly<{
[LogtoOidcConfigKey.CookieKeys]: z.string().array(),
});
// Admin console config
export const adminConsoleDataGuard = z.object({
// Get started challenges
demoChecked: z.boolean(),
applicationCreated: z.boolean(),
signInExperienceCustomized: z.boolean(),
passwordlessConfigured: z.boolean(),
socialSignInConfigured: z.boolean(),
furtherReadingsChecked: z.boolean(),
});
export type AdminConsoleData = z.infer<typeof adminConsoleDataGuard>;
export enum AdminConsoleConfigKey {
AdminConsole = 'adminConsole',
}
export type AdminConsoleConfigType = {
[AdminConsoleConfigKey.AdminConsole]: AdminConsoleData;
};
export const adminConsoleConfigGuard: Readonly<{
[key in AdminConsoleConfigKey]: ZodType<AdminConsoleConfigType[key]>;
}> = Object.freeze({
[AdminConsoleConfigKey.AdminConsole]: adminConsoleDataGuard,
});
// Summary
export type LogtoConfigKey = AlterationStateKey | LogtoOidcConfigKey;
export type LogtoConfigType = AlterationStateType | LogtoOidcConfigType;
export type LogtoConfigGuard = typeof alterationStateGuard & typeof logtoOidcConfigGuard;
export type LogtoConfigKey = AlterationStateKey | LogtoOidcConfigKey | AdminConsoleConfigKey;
export type LogtoConfigType = AlterationStateType | LogtoOidcConfigType | AdminConsoleConfigType;
export type LogtoConfigGuard = typeof alterationStateGuard &
typeof logtoOidcConfigGuard &
typeof adminConsoleConfigGuard;
export const logtoConfigKeys: readonly LogtoConfigKey[] = Object.freeze([
...Object.values(AlterationStateKey),
...Object.values(LogtoOidcConfigKey),
...Object.values(AdminConsoleConfigKey),
]);
export const logtoConfigGuards: LogtoConfigGuard = Object.freeze({
...alterationStateGuard,
...logtoOidcConfigGuard,
...adminConsoleConfigGuard,
});

View file

@ -0,0 +1,10 @@
create table logto_configs (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
key varchar(256) not null,
value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
primary key (tenant_id, key)
);
create trigger set_tenant_id before insert on logto_configs
for each row execute procedure set_tenant_id();

View file

@ -1,13 +0,0 @@
create table settings (
tenant_id varchar(21) not null
references tenants (id) on update cascade on delete cascade,
id varchar(21) not null,
admin_console jsonb /* @use AdminConsoleConfig */ not null,
primary key (id)
);
create index settings__id
on settings (tenant_id, id);
create trigger set_tenant_id before insert on settings
for each row execute procedure set_tenant_id();

View file

@ -1,4 +1,4 @@
create table _logto_configs (
create table systems (
key varchar(256) not null,
value jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
primary key (key)