diff --git a/packages/cli/package.json b/packages/cli/package.json index 135a6d040..bb33dbf5c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -37,8 +37,10 @@ "prettier": "@silverhand/eslint-config/.prettierrc", "dependencies": { "@logto/schemas": "^0.1.0", + "decamelize": "^5.0.0", "roarr": "^7.11.0", "slonik": "^28.1.0", + "slonik-interceptor-preset": "^1.2.10", "slonik-sql-tag-raw": "^1.1.4" } } diff --git a/packages/cli/src/database.ts b/packages/cli/src/database.ts index 90cb0d803..0880f1c76 100644 --- a/packages/cli/src/database.ts +++ b/packages/cli/src/database.ts @@ -1,13 +1,19 @@ import { readdir, readFile } from 'fs/promises'; import path from 'path'; +import { seeds } from '@logto/schemas'; import { createPool, sql } from 'slonik'; +import { createInterceptors } from 'slonik-interceptor-preset'; import { raw } from 'slonik-sql-tag-raw'; +import { insertInto } from './utilities'; + +const { managementResource, defaultSignInExperience, createDefaultSetting } = seeds; const tableDirectory = 'node_modules/@logto/schemas/tables'; +const domain = 'http://localhost:3001'; export const createDatabaseCli = (uri: string) => { - const pool = createPool(uri); + const pool = createPool(uri, { interceptors: createInterceptors() }); const createTables = async () => { const directory = await readdir(tableDirectory); @@ -23,13 +29,22 @@ export const createDatabaseCli = (uri: string) => { for (const [file, query] of queries) { // eslint-disable-next-line no-await-in-loop await pool.query(sql`${raw(query)}`); - console.log(`Run ${file} succeeded.`); + console.log(`Create Tables: Run ${file} succeeded.`); } }; - return { createTables }; + const seedTables = async () => { + await Promise.all([ + pool.query(insertInto(managementResource, 'resources')), + pool.query(insertInto(createDefaultSetting(domain), 'settings')), + pool.query(insertInto(defaultSignInExperience, 'sign_in_experiences')), + ]); + console.log('Seed Tables: Seed tables succeeded.'); + }; + + return { createTables, seedTables }; }; // For testing purpose, will remove later const cli = createDatabaseCli(process.env.DSN ?? ''); -void cli.createTables(); +void cli.seedTables(); diff --git a/packages/cli/src/include.d/slonik-interceptor-preset.d.ts b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts new file mode 100644 index 000000000..bd5cb79a9 --- /dev/null +++ b/packages/cli/src/include.d/slonik-interceptor-preset.d.ts @@ -0,0 +1,10 @@ +declare module 'slonik-interceptor-preset' { + import { InterceptorType } from 'slonik'; + + export const createInterceptors: (config?: { + benchmarkQueries: boolean; + logQueries: boolean; + normaliseQueries: boolean; + transformFieldNames: boolean; + }) => readonly InterceptorType[]; +} diff --git a/packages/cli/src/utilities.ts b/packages/cli/src/utilities.ts new file mode 100644 index 000000000..9affa2ced --- /dev/null +++ b/packages/cli/src/utilities.ts @@ -0,0 +1,54 @@ +// LOG-2133 Create `shared` package for common utilities + +import { SchemaLike, SchemaValue, SchemaValuePrimitive } from '@logto/schemas'; +import decamelize from 'decamelize'; +import { sql, SqlToken } from 'slonik'; + +/** + * Note `undefined` is removed from the acceptable list, + * since you should NOT call this function if ignoring the field is the desired behavior. + * Calling this function with `null` means an explicit `null` setting in database is expected. + * @param key The key of value. Will treat as `timestamp` if it ends with `_at` or 'At' AND value is a number; + * @param value The value to convert. + * @returns A primitive that can be saved into database. + */ +export const convertToPrimitiveOrSql = ( + key: string, + // eslint-disable-next-line @typescript-eslint/ban-types + value: NonNullable | null + // eslint-disable-next-line @typescript-eslint/ban-types +): NonNullable | SqlToken | null => { + if (value === null) { + return null; + } + + if (typeof value === 'object') { + return JSON.stringify(value); + } + + if (['_at', 'At'].some((value) => key.endsWith(value)) && typeof value === 'number') { + return sql`to_timestamp(${value / 1000})`; + } + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return value; + } + + throw new Error(`Cannot convert ${key} to primitive`); +}; + +export const insertInto = (object: T, table: string) => { + const keys = Object.keys(object); + + return sql` + insert into ${sql.identifier([table])} + (${sql.join( + keys.map((key) => sql.identifier([decamelize(key)])), + sql`, ` + )}) + values (${sql.join( + keys.map((key) => convertToPrimitiveOrSql(key, object[key] ?? null)), + sql`, ` + )}) + `; +}; diff --git a/packages/console/src/pages/Applications/components/CreateForm/index.tsx b/packages/console/src/pages/Applications/components/CreateForm/index.tsx index 5a787b913..685b54bd1 100644 --- a/packages/console/src/pages/Applications/components/CreateForm/index.tsx +++ b/packages/console/src/pages/Applications/components/CreateForm/index.tsx @@ -1,15 +1,14 @@ -import { Application, ApplicationType, Setting } from '@logto/schemas'; +import { Application, ApplicationType } from '@logto/schemas'; import React, { useState } from 'react'; import { useController, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import useSWR from 'swr'; import Button from '@/components/Button'; import FormField from '@/components/FormField'; import ModalLayout from '@/components/ModalLayout'; import RadioGroup, { Radio } from '@/components/RadioGroup'; import TextInput from '@/components/TextInput'; -import useApi, { RequestError } from '@/hooks/use-api'; +import useApi from '@/hooks/use-api'; import { applicationTypeI18nKey } from '@/types/applications'; import { GetStartedForm } from '@/types/get-started'; @@ -40,11 +39,8 @@ const CreateForm = ({ onClose }: Props) => { field: { onChange, value, name, ref }, } = useController({ name: 'type', control, rules: { required: true } }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); - const { data: setting } = useSWR('/api/settings'); const api = useApi(); - const isGetStartedSkipped = setting?.adminConsole.applicationSkipGetStarted; - const closeModal = () => { setIsGetStartedModalOpen(false); onClose?.(createdApp); @@ -58,11 +54,7 @@ const CreateForm = ({ onClose }: Props) => { const createdApp = await api.post('/api/applications', { json: data }).json(); setCreatedApp(createdApp); - if (isGetStartedSkipped) { - closeModal(); - } else { - setIsGetStartedModalOpen(true); - } + setIsGetStartedModalOpen(true); }); const onComplete = async (data: GetStartedForm) => { @@ -124,7 +116,7 @@ const CreateForm = ({ onClose }: Props) => { - {!isGetStartedSkipped && createdApp && ( + {createdApp && ( { it('findDefaultSignInExperience', async () => { /* eslint-disable sql/no-unsafe-query */ const expectSql = ` - select "id", "branding", "language_info", "terms_of_use", "forget_password_enabled", "sign_in_methods", "social_sign_in_connector_ids" + select "id", "branding", "language_info", "terms_of_use", "sign_in_methods", "social_sign_in_connector_ids" from "sign_in_experiences" where "id" = $1 `; diff --git a/packages/schemas/src/db-entries/sign-in-experience.ts b/packages/schemas/src/db-entries/sign-in-experience.ts index 6b260f3ab..86fc165b5 100644 --- a/packages/schemas/src/db-entries/sign-in-experience.ts +++ b/packages/schemas/src/db-entries/sign-in-experience.ts @@ -22,7 +22,6 @@ export type CreateSignInExperience = { branding?: Branding; languageInfo?: LanguageInfo; termsOfUse?: TermsOfUse; - forgetPasswordEnabled?: boolean; signInMethods?: SignInMethods; socialSignInConnectorIds?: ConnectorIds; }; @@ -32,7 +31,6 @@ export type SignInExperience = { branding: Branding; languageInfo: LanguageInfo; termsOfUse: TermsOfUse; - forgetPasswordEnabled: boolean; signInMethods: SignInMethods; socialSignInConnectorIds: ConnectorIds; }; @@ -42,7 +40,6 @@ const createGuard: Guard = z.object({ branding: brandingGuard.optional(), languageInfo: languageInfoGuard.optional(), termsOfUse: termsOfUseGuard.optional(), - forgetPasswordEnabled: z.boolean().optional(), signInMethods: signInMethodsGuard.optional(), socialSignInConnectorIds: connectorIdsGuard.optional(), }); @@ -55,7 +52,6 @@ export const SignInExperiences: GeneratedSchema = Object branding: 'branding', languageInfo: 'language_info', termsOfUse: 'terms_of_use', - forgetPasswordEnabled: 'forget_password_enabled', signInMethods: 'sign_in_methods', socialSignInConnectorIds: 'social_sign_in_connector_ids', }, @@ -64,7 +60,6 @@ export const SignInExperiences: GeneratedSchema = Object 'branding', 'languageInfo', 'termsOfUse', - 'forgetPasswordEnabled', 'signInMethods', 'socialSignInConnectorIds', ], diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 5bdddb7c6..6631706fb 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -76,9 +76,7 @@ export type UserLogPayload = z.infer; * Settings */ -export const adminConsoleConfigGuard = z.object({ - applicationSkipGetStarted: z.boolean(), -}); +export const adminConsoleConfigGuard = z.object({}); export type AdminConsoleConfig = z.infer; @@ -95,10 +93,8 @@ export const hexColorRegEx = /^#[\da-f]{3}([\da-f]{3})?$/i; export const brandingGuard = z.object({ primaryColor: z.string().regex(hexColorRegEx), - backgroundColor: z.string().regex(hexColorRegEx), - darkMode: z.boolean(), + isDarkModeEnabled: z.boolean(), darkPrimaryColor: z.string().regex(hexColorRegEx), - darkBackgroundColor: z.string().regex(hexColorRegEx), style: z.nativeEnum(BrandingStyle), logoUrl: z.string().url(), slogan: z.string().nonempty().optional(), diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 1ad4f4d41..19f0dd248 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -2,3 +2,4 @@ export * from './foundations'; export * from './db-entries'; export * from './types'; export * from './api'; +export * as seeds from './seeds'; diff --git a/packages/schemas/src/seeds/index.ts b/packages/schemas/src/seeds/index.ts new file mode 100644 index 000000000..cc4c1a610 --- /dev/null +++ b/packages/schemas/src/seeds/index.ts @@ -0,0 +1,3 @@ +export * from './resource'; +export * from './setting'; +export * from './sign-in-experience'; diff --git a/packages/schemas/src/seeds/resource.ts b/packages/schemas/src/seeds/resource.ts new file mode 100644 index 000000000..adf21bb72 --- /dev/null +++ b/packages/schemas/src/seeds/resource.ts @@ -0,0 +1,7 @@ +import { CreateResource } from '../db-entries'; + +export const managementResource: Readonly = Object.freeze({ + id: 'management-api', + indicator: 'https://logto.io/api', + name: 'Logto Management API', +}); diff --git a/packages/schemas/src/seeds/setting.ts b/packages/schemas/src/seeds/setting.ts new file mode 100644 index 000000000..bbab2af04 --- /dev/null +++ b/packages/schemas/src/seeds/setting.ts @@ -0,0 +1,10 @@ +import { CreateSetting } from '../db-entries'; + +export const defaultSettingId = 'default'; + +export const createDefaultSetting = (customDomain: string): Readonly => + Object.freeze({ + id: defaultSettingId, + customDomain, + adminConsole: {}, + }); diff --git a/packages/schemas/src/seeds/sign-in-experience.ts b/packages/schemas/src/seeds/sign-in-experience.ts new file mode 100644 index 000000000..e0ce94c0d --- /dev/null +++ b/packages/schemas/src/seeds/sign-in-experience.ts @@ -0,0 +1,28 @@ +import { CreateSignInExperience } from '../db-entries'; +import { BrandingStyle, Language, SignInMethodState } from '../foundations'; + +export const defaultSignInExperience: Readonly = { + id: 'default', + branding: { + primaryColor: '#6139F6', + isDarkModeEnabled: false, + darkPrimaryColor: '#6139F6', + style: BrandingStyle.Logo, + logoUrl: 'https://logto.io/logo.svg', + }, + languageInfo: { + autoDetect: true, + fallbackLanguage: Language.english, + fixedLanguage: Language.english, + }, + termsOfUse: { + enabled: false, + }, + signInMethods: { + username: SignInMethodState.primary, + email: SignInMethodState.disabled, + sms: SignInMethodState.disabled, + social: SignInMethodState.disabled, + }, + socialSignInConnectorIds: [], +}; diff --git a/packages/schemas/tables/sign_in_experiences.sql b/packages/schemas/tables/sign_in_experiences.sql index 5b7b00f9d..e7c25fac8 100644 --- a/packages/schemas/tables/sign_in_experiences.sql +++ b/packages/schemas/tables/sign_in_experiences.sql @@ -3,7 +3,6 @@ create table sign_in_experiences ( branding jsonb /* @use Branding */ not null default '{}'::jsonb, language_info jsonb /* @use LanguageInfo */ not null default '{}'::jsonb, terms_of_use jsonb /* @use TermsOfUse */ not null default '{}'::jsonb, - forget_password_enabled boolean not null default false, sign_in_methods jsonb /* @use SignInMethods */ not null default '{}'::jsonb, social_sign_in_connector_ids jsonb /* @use ConnectorIds */ not null default '[]'::jsonb, primary key (id) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11e9b2355..cc0087c52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,18 +24,22 @@ importers: '@silverhand/eslint-config': ^0.10.2 '@silverhand/ts-config': ^0.10.2 '@types/node': '14' + decamelize: ^5.0.0 eslint: ^8.10.0 lint-staged: ^11.1.1 prettier: ^2.3.2 roarr: ^7.11.0 slonik: ^28.1.0 + slonik-interceptor-preset: ^1.2.10 slonik-sql-tag-raw: ^1.1.4 ts-node: ^10.0.0 typescript: ^4.6.3 dependencies: '@logto/schemas': link:../schemas + decamelize: 5.0.1 roarr: 7.11.0 slonik: 28.1.0 + slonik-interceptor-preset: 1.2.10 slonik-sql-tag-raw: 1.1.4_roarr@7.11.0+slonik@28.1.0 devDependencies: '@silverhand/eslint-config': 0.10.2_bbe1a6794670f389df81805f22999709 @@ -2338,6 +2342,7 @@ packages: dependencies: '@babel/helper-validator-identifier': 7.15.7 to-fast-properties: 2.0.0 + dev: true /@babel/types/7.17.0: resolution: {integrity: sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==} @@ -6886,9 +6891,9 @@ packages: /babel-plugin-macros/2.8.0: resolution: {integrity: sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==} dependencies: - '@babel/runtime': 7.16.3 + '@babel/runtime': 7.17.9 cosmiconfig: 6.0.0 - resolve: 1.20.0 + resolve: 1.22.0 dev: false /babel-plugin-polyfill-corejs2/0.3.1_@babel+core@7.17.9: @@ -7314,6 +7319,7 @@ packages: /camelcase/6.2.1: resolution: {integrity: sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA==} engines: {node: '>=10'} + dev: true /camelcase/6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} @@ -7947,11 +7953,6 @@ packages: requiresBuild: true dev: false - /core-js/3.19.3: - resolution: {integrity: sha512-LeLBMgEGSsG7giquSzvgBrTS7V5UL6ks3eQlUSbN8dJStlLFiRzUm5iqsRyzUB8carhfKjkJ2vzKqE6z1Vga9g==} - requiresBuild: true - dev: false - /core-js/3.21.1: resolution: {integrity: sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==} requiresBuild: true @@ -10937,7 +10938,7 @@ packages: /inline-loops.macro/1.2.2: resolution: {integrity: sha512-w5cOGQGnNoBTSibg6IzaIG2OG9sbXJxTn3uzYP717C/SvcJVEFz5Zu1dJwvCLlnwtBWQgcfnV2BNEQaRoIAfIw==} dependencies: - '@babel/types': 7.16.0 + '@babel/types': 7.17.0 babel-plugin-macros: 2.8.0 dev: false @@ -11070,6 +11071,7 @@ packages: resolution: {integrity: sha512-vd15qHsaqrRL7dtH6QNuy0ndJmRDrS9HAM1CAiSifNUFv4x1a0CCVsj18hJ1mShxIG6T2i1sO78MkP56r0nYRw==} dependencies: has: 1.0.3 + dev: true /is-core-module/2.8.1: resolution: {integrity: sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==} @@ -16717,6 +16719,7 @@ packages: dependencies: is-core-module: 2.8.0 path-parse: 1.0.7 + dev: true /resolve/1.22.0: resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} @@ -17201,8 +17204,8 @@ packages: peerDependencies: slonik: '*' dependencies: - camelcase: 6.2.1 - core-js: 3.19.3 + camelcase: 6.3.0 + core-js: 3.21.1 slonik: 22.7.1 dev: false @@ -17223,7 +17226,7 @@ packages: resolution: {integrity: sha512-f9jxhsu+8u0ssf2pdzLx1jSlGODkAitNbGrprJWGOjmnQzZKW4jWaq54DZGwyNv4HotOb9m4Lp0u9XQODKXyng==} engines: {node: '>=8.0'} dependencies: - core-js: 3.19.3 + core-js: 3.21.1 pg-formatter: 1.3.0 pretty-ms: 6.0.1 slonik: 22.7.1 @@ -17249,7 +17252,7 @@ packages: resolution: {integrity: sha512-TAuWVFBVnq7I5KcEY/x1JgVgIVZ0yyyeRlMTzKs+u4wRYhszQW2hMIYnDak/UUfWR1h6wp3+hODiC4gKyBOUcg==} engines: {node: '>=8.0'} dependencies: - core-js: 3.19.3 + core-js: 3.21.1 slonik: 22.7.1 transitivePeerDependencies: - pg-native @@ -17279,11 +17282,11 @@ packages: inline-loops.macro: 1.2.2 is-plain-object: 5.0.0 iso8601-duration: 1.3.0 - pg: 8.7.1 + pg: 8.7.3 pg-connection-string: 2.5.0 pg-copy-streams: 5.1.1 pg-copy-streams-binary: 2.2.0 - pg-cursor: 2.7.1_pg@8.7.1 + pg-cursor: 2.7.3_pg@8.7.3 pg-types: 3.0.1 postgres-array: 2.0.0 postgres-interval: 2.1.0