Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions packages/cli/src/commands/database/seed/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { IdFormat, isIdFormat } from '@logto/shared';
import { getEnv } from '@silverhand/essentials';
import type { DatabasePool } from '@silverhand/slonik';
import type { CommandModule } from 'yargs';

import { createPoolAndDatabaseIfNeeded } from '../../../database.js';
import { doesConfigsTableExist } from '../../../queries/logto-config.js';
import { consoleLog, oraPromise } from '../../../utils.js';
import { consoleLog, isTty, oraPromise } from '../../../utils.js';
import { getLatestAlterationTimestamp } from '../alteration/index.js';
import { getAlterationDirectory } from '../alteration/utils.js';

import { createTables, seedCloud, seedTables, seedTest } from './tables.js';
import { promptIdFormat } from './utils.js';

export const seedByPool = async (
pool: DatabasePool,
Expand Down Expand Up @@ -52,6 +55,38 @@ const seedLegacyTestData = async (pool: DatabasePool) => {
});
};

/**
* Resolve the ID format and set it in `process.env.ID_FORMAT` so all seed
* functions can read it via `getIdFormat()` from `@logto/shared`.
*
* Priority: CLI option > ENV variable > interactive prompt > default ('nanoid')
*/
const resolveIdFormat = async (cliIdFormat?: string): Promise<void> => {
// CLI option takes highest priority
if (cliIdFormat) {
process.env.ID_FORMAT = cliIdFormat;
return;
}

// Check ENV variable (already set in process.env)
const envFormat = getEnv('ID_FORMAT');
if (envFormat) {
if (!isIdFormat(envFormat)) {
throw new Error(
`Invalid ID_FORMAT environment variable: '${envFormat}'. Must be 'nanoid' or 'uuid'.`
);
}
return;
}

// Interactive prompt in TTY mode
if (isTty()) {
process.env.ID_FORMAT = await promptIdFormat();
}

// Default: nanoid (getIdFormat() returns 'nanoid' when ID_FORMAT is unset)
};

const seed: CommandModule<
Record<string, unknown>,
{
Expand All @@ -60,6 +95,7 @@ const seed: CommandModule<
test?: boolean;
'legacy-test-data'?: boolean;
'encrypt-base-role'?: boolean;
'id-format'?: string;
}
> = {
command: 'seed [type]',
Expand Down Expand Up @@ -87,8 +123,14 @@ const seed: CommandModule<
.option('encrypt-base-role', {
describe: 'Seed base role with password',
type: 'boolean',
})
.option('id-format', {
describe:
'ID format for all entity types (nanoid or uuid). This choice is permanent and cannot be changed after installation. Defaults to ID_FORMAT env variable or nanoid.',
type: 'string',
choices: Object.values(IdFormat),
}),
handler: async ({ swe, cloud, test, legacyTestData, encryptBaseRole }) => {
handler: async ({ swe, cloud, test, legacyTestData, encryptBaseRole, idFormat }) => {
const pool = await createPoolAndDatabaseIfNeeded();

if (legacyTestData) {
Expand All @@ -112,6 +154,8 @@ const seed: CommandModule<
}

try {
await resolveIdFormat(idFormat);

await seedByPool(pool, cloud, test, encryptBaseRole);
} catch (error: unknown) {
consoleLog.error(error);
Expand Down
38 changes: 30 additions & 8 deletions packages/cli/src/commands/database/seed/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
createDefaultAdminConsoleConfig,
defaultTenantId,
adminTenantId,
defaultManagementApi,
createDefaultManagementApi,
createAdminDataInAdminTenant,
createMeApiInAdminTenant,
createDefaultSignInExperience,
createAdminTenantSignInExperience,
createDefaultAdminConsoleApplication,
Applications,
createCloudApi,
createTenantApplicationRole,
CloudScope,
Expand All @@ -19,18 +20,24 @@ import {
UsersRoles,
LogtoConfigs,
SignInExperiences,
Applications,
OrganizationUserRelations,
getTenantOrganizationId,
Users,
OrganizationRoleUserRelations,
TenantRole,
AccountCenters,
Systems,
} from '@logto/schemas';
import { getTenantRole } from '@logto/schemas';
import { createDefaultAccountCenter } from '@logto/schemas/lib/seeds/account-center.js';
import { Tenants } from '@logto/schemas/models';
import { generateStandardId } from '@logto/shared';
import {
IdFormat,
generateStandardId,
generateStandardSecret,
buildSeedId,
getIdFormat,
} from '@logto/shared';
import type { DatabaseTransactionConnection } from '@silverhand/slonik';
import { sql } from '@silverhand/slonik';

Expand Down Expand Up @@ -99,6 +106,9 @@ export const createTables = async (
])
);

// Resolve the ${id_format} variable in SQL: native uuid type or varchar(21) for nanoid
const idFormatType = getIdFormat() === IdFormat.Uuid ? 'uuid' : 'varchar(21)';

const runLifecycleQuery = async (
lifecycle: Lifecycle,
parameters: { name?: string; database?: string; password?: string } = {}
Expand All @@ -125,13 +135,15 @@ export const createTables = async (
];
const sorted = allQueries.slice().sort(compareQuery);
const database = await getDatabaseName(connection, true);
const password = encryptBaseRole ? generateStandardId(32) : '';
const password = encryptBaseRole ? generateStandardSecret() : '';

await runLifecycleQuery('before_all', { database, password });

/* eslint-disable no-await-in-loop */
for (const [file, query] of sorted) {
await connection.query(sql`${sql.raw(query)}`);
// eslint-disable-next-line no-template-curly-in-string
const resolvedQuery = query.replaceAll('${id_format}', idFormatType);
await connection.query(sql`${sql.raw(resolvedQuery)}`);

if (!query.includes('/* no_after_each */')) {
await runLifecycleQuery('after_each', { name: file.split('.')[0], database });
Expand All @@ -151,7 +163,7 @@ export const seedTables = async (
) => {
await createTenant(connection, defaultTenantId);
await seedOidcConfigs(connection, defaultTenantId);
await seedAdminData(connection, defaultManagementApi);
await seedAdminData(connection, createDefaultManagementApi());

/**
* Create a pre-configured role for the Logto Management API access
Expand Down Expand Up @@ -205,6 +217,12 @@ export const seedTables = async (
connection.query(insertInto(createDefaultAdminConsoleApplication(), Applications.table)),
connection.query(insertInto(createDefaultAccountCenter(defaultTenantId), AccountCenters.table)),
connection.query(insertInto(createDefaultAccountCenter(adminTenantId), AccountCenters.table)),
// Store the chosen ID format permanently in the systems table
connection.query(sql`
insert into ${sql.identifier([Systems.table])} (key, value)
values (${'idFormat'}, ${sql.jsonb({ format: getIdFormat() })})
Comment thread
Zyles marked this conversation as resolved.
on conflict (key) do update set value = excluded.value
`),
]);

// The below seed data is for the Logto Cloud only. We put it here for the sake of simplicity.
Expand Down Expand Up @@ -253,7 +271,7 @@ export const seedTest = async (connection: DatabaseTransactionConnection, forLeg
)
);

const userIds = Object.freeze(['test-1', 'test-2'] as const);
const userIds = Object.freeze([buildSeedId('test-1'), buildSeedId('test-2')] as const);
await Promise.all([
connection.query(
insertInto({ id: userIds[0], username: 'test1', tenantId: adminTenantId }, Users.table)
Expand Down Expand Up @@ -290,7 +308,11 @@ export const seedTest = async (connection: DatabaseTransactionConnection, forLeg
const addOrganizationMembership = async (userId: string, tenantId: string) =>
connection.query(
insertInto(
{ userId, organizationId: getTenantOrganizationId(tenantId), tenantId: adminTenantId },
{
userId,
organizationId: getTenantOrganizationId(tenantId),
tenantId: adminTenantId,
},
OrganizationUserRelations.table
)
);
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/commands/database/seed/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import chalk from 'chalk';
import inquirer from 'inquirer';

import { consoleLog } from '../../../utils.js';

/**
* Prompt the user to select ID format for all entity types.
* This is used during database seeding to configure the ID format for the instance.
*
* @returns The selected format string ('nanoid' or 'uuid')
*/
export const promptIdFormat = async (): Promise<string> => {
consoleLog.info(
'\n' +
chalk.bold('ID Format Configuration') +
'\n\n' +
'Logto supports two ID formats:\n' +
` ${chalk.green('nanoid')} - Compact, URL-safe IDs (12-21 characters, e.g., "a1b2c3d4e5f6")\n` +
` ${chalk.magenta('uuid')} - UUID v7 format (36 characters, time-ordered, e.g., "018e8c3a-9d2e-7890-a123-456789abcdef")\n\n` +
chalk.red('Warning:') +
' This choice is permanent and cannot be changed after installation.\n' +
chalk.yellow('Tip:') +
' Set the ID_FORMAT environment variable to skip this prompt.\n'
);

const { format } = await inquirer.prompt<{ format: string }>({
type: 'list',
name: 'format',
message: 'Select ID format for all entities (users, organizations, roles):',
choices: [
{
name: `${chalk.green('nanoid')} - Compact, URL-safe`,
value: 'nanoid',
},
{
name: `${chalk.magenta('uuid')} - UUID v7`,
value: 'uuid',
},
],
default: 'nanoid',
});

consoleLog.info(`\n${chalk.bold('Selected ID format:')} ${chalk.green(format)}\n`);

return format;
};
14 changes: 14 additions & 0 deletions packages/cli/src/commands/install/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { isIdFormat } from '@logto/shared';
import { assert } from '@silverhand/essentials';
import chalk from 'chalk';
import { got, RequestError } from 'got';
Expand All @@ -24,6 +25,7 @@ import {
safeExecSync,
} from '../../utils.js';
import { seedByPool } from '../database/seed/index.js';
import { promptIdFormat } from '../database/seed/utils.js';

const pgRequired = new semver.SemVer('14.0.0');

Expand Down Expand Up @@ -164,6 +166,18 @@ export const decompress = async (toPath: string, tarPath: string) => {
export const seedDatabase = async (instancePath: string, cloud: boolean) => {
try {
const pool = await createPoolAndDatabaseIfNeeded();

// Resolve ID format: ENV variable > interactive prompt > default (nanoid)
const envFormat = process.env.ID_FORMAT;
if (envFormat && !isIdFormat(envFormat)) {
throw new Error(
`Invalid ID_FORMAT environment variable: '${envFormat}'. Must be 'nanoid' or 'uuid'.`
);
}
if (!envFormat && isTty()) {
process.env.ID_FORMAT = await promptIdFormat();
}

await seedByPool(pool, cloud);
await pool.end();
} catch (error: unknown) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ function InvitationList({ invitations }: Props) {
});
const data = await cloudApi.get('/api/tenants');
resetTenants(data);
navigateTenant(getTenantIdFromOrganizationId(organizationId));
navigateTenant(
getTenantIdFromOrganizationId(
organizationId,
data.map(({ id }) => id)
)
);
} finally {
setIsJoining(false);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ function TenantInvitationDropdownItem({ data }: Props) {
});
const data = await cloudApi.get('/api/tenants');
resetTenants(data);
navigateTenant(getTenantIdFromOrganizationId(organizationId));
navigateTenant(
getTenantIdFromOrganizationId(
organizationId,
data.map(({ id }) => id)
)
);
}}
/>
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/console/src/containers/ConsoleContent/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ const useTenantScopeListener = () => {
const { scopes, isLoading } = useCurrentTenantScopes();

useEffect(() => {
// Tenant scope listening is only relevant for Cloud, where multi-tenant
// organization-based access control is used. OSS has a single tenant with no scope changes.
if (!isCloud) {
return;
}
(async () => {
const organizationId = getTenantOrganizationId(currentTenantId);
const claims = await getOrganizationTokenClaims(organizationId);
Expand Down
7 changes: 6 additions & 1 deletion packages/console/src/pages/AcceptInvitation/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,12 @@ function AcceptInvitation() {

const data = await cloudApi.get('/api/tenants');
resetTenants(data);
navigateTenant(getTenantIdFromOrganizationId(organizationId));
navigateTenant(
getTenantIdFromOrganizationId(
organizationId,
data.map(({ id }) => id)
)
);
})();
}, [cloudApi, error, invitation, navigateTenant, resetTenants, t]);

Expand Down
4 changes: 2 additions & 2 deletions packages/console/src/pages/Connectors/Guide/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { isLanguageTag } from '@logto/language-kit';
import { ConnectorType } from '@logto/schemas';
import type { ConnectorFactoryResponse, RequestErrorBody } from '@logto/schemas';
import { generateStandardId } from '@logto/shared/universal';
import { generateStandardShortId } from '@logto/shared/universal';
import { conditional } from '@silverhand/essentials';
import i18next from 'i18next';
import { HTTPError } from 'ky';
Expand Down Expand Up @@ -45,7 +45,7 @@ type Props = {
function Guide({ connector, onClose }: Props) {
const { createConnector } = useConnectorApi();
const { navigate } = useTenantPathname();
const callbackConnectorId = useRef(generateStandardId());
const callbackConnectorId = useRef(generateStandardShortId());
const [conflictConnectorName, setConflictConnectorName] = useState<Record<string, string>>();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { SocialUserInfo } from '@logto/connector-kit';
import { socialUserInfoGuard } from '@logto/connector-kit';
import { Theme } from '@logto/schemas';
import type { ConnectorResponse, UserProfileResponse } from '@logto/schemas';
import { generateStandardId } from '@logto/shared/universal';
import { generateStandardShortId } from '@logto/shared/universal';
import type { Optional } from '@silverhand/essentials';
import { appendPath, conditional } from '@silverhand/essentials';
import { useCallback, useMemo } from 'react';
Expand Down Expand Up @@ -42,7 +42,7 @@ function LinkAccountSection({ user, connectors, onUpdate }: Props) {
const getSocialAuthorizationUri = useCallback(
async (connectorId: string) => {
const adminTenantEndpointUrl = new URL(adminTenantEndpoint);
const state = generateStandardId(8);
const state = generateStandardShortId();
const redirectUri = new URL(`/callback/${connectorId}`, adminTenantEndpointUrl).href;
const { redirectTo } = await api
.post('me/social/authorization-uri', { json: { connectorId, state, redirectUri } })
Expand Down
Loading
Loading