0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

Merge pull request #7239 from logto-io/charles-log-10872-core-get-one-time-tokens

feat(core): add management API to batch fetch one-time tokens
This commit is contained in:
Charles Zhao 2025-04-07 11:19:28 +08:00 committed by GitHub
parent 97d713aaec
commit 387cdfdb9a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 142 additions and 4 deletions

View file

@ -5,7 +5,8 @@ import { sql } from '@silverhand/slonik';
import { buildDeleteByIdWithPool } from '#src/database/delete-by-id.js';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { getTotalRowCountWithPool } from '#src/database/row-count.js';
import { conditionalSql, convertToIdentifiers } from '#src/utils/sql.js';
const { table, fields } = convertToIdentifiers(OneTimeTokens);
@ -14,6 +15,31 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => {
returning: true,
});
const findTotalNumberOfOneTimeTokens = async () => getTotalRowCountWithPool(pool)(table);
const getOneTimeTokens = async (
where: { email?: string; status?: OneTimeTokenStatus },
pagination?: { limit: number; offset: number }
) => {
const whereEntries = Object.entries(where).filter(([_, value]) => Boolean(value));
return pool.any<OneTimeToken>(sql`
select ${sql.join(Object.values(fields), sql`, `)}
from ${table}
${conditionalSql(
whereEntries.length > 0 && whereEntries,
(whereEntries) =>
sql`where ${sql.join(
whereEntries.map(([column, value]) =>
conditionalSql(value, (value) => sql`${sql.identifier([column])} = ${value}`)
),
sql` and `
)}`
)}
order by ${fields.createdAt} desc
${conditionalSql(pagination, ({ limit, offset }) => sql`limit ${limit} offset ${offset}`)}
`);
};
const getOneTimeTokenById = buildFindEntityByIdWithPool(pool)(OneTimeTokens);
const deleteOneTimeTokenById = buildDeleteByIdWithPool(pool, OneTimeTokens.table);
@ -44,10 +70,12 @@ export const createOneTimeTokenQueries = (pool: CommonQueryMethods) => {
return {
deleteOneTimeTokenById,
insertOneTimeToken,
updateExpiredOneTimeTokensStatusByEmail,
findTotalNumberOfOneTimeTokens,
getOneTimeTokens,
getOneTimeTokenById,
getOneTimeTokenByToken,
insertOneTimeToken,
updateExpiredOneTimeTokensStatusByEmail,
updateOneTimeTokenStatus,
};
};

View file

@ -10,6 +10,29 @@
],
"paths": {
"/api/one-time-tokens": {
"get": {
"summary": "Get one-time tokens",
"description": "Get a list of one-time tokens, filtering by email and status, with optional pagination.",
"parameters": [
{
"name": "email",
"in": "query",
"description": "Filter one-time tokens by email address.",
"required": false
},
{
"name": "status",
"in": "query",
"description": "Filter one-time tokens by status.",
"required": false
}
],
"responses": {
"200": {
"description": "A list of one-time tokens."
}
}
},
"post": {
"summary": "Create one-time token",
"description": "Create a new one-time token associated with an email address. The token can be used for verification purposes and has an expiration time.",

View file

@ -1,10 +1,12 @@
import { emailRegEx } from '@logto/core-kit';
import { OneTimeTokens, oneTimeTokenStatusGuard } from '@logto/schemas';
import { generateStandardId, generateStandardSecret } from '@logto/shared';
import { trySafe } from '@silverhand/essentials';
import { cond, trySafe } from '@silverhand/essentials';
import { addSeconds } from 'date-fns';
import { z } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import type { ManagementApiRouter, RouterInitArgs } from './types.js';
@ -18,8 +20,10 @@ export default function oneTimeTokenRoutes<T extends ManagementApiRouter>(
queries: {
oneTimeTokens: {
deleteOneTimeTokenById,
findTotalNumberOfOneTimeTokens,
insertOneTimeToken,
updateExpiredOneTimeTokensStatusByEmail,
getOneTimeTokens,
getOneTimeTokenById,
},
},
@ -29,6 +33,34 @@ export default function oneTimeTokenRoutes<T extends ManagementApiRouter>(
},
]: RouterInitArgs<T>
) {
router.get(
'/one-time-tokens',
koaPagination({ isOptional: true }),
koaGuard({
query: z.object({
email: z.string().regex(emailRegEx).optional(),
status: oneTimeTokenStatusGuard.optional(),
}),
response: z.array(OneTimeTokens.guard),
status: 200,
}),
async (ctx, next) => {
const { guard, pagination } = ctx;
const { email, status } = guard.query;
const { disabled: isPaginationDisabled } = pagination;
const [{ count }, oneTimeTokens] = await Promise.all([
findTotalNumberOfOneTimeTokens(),
getOneTimeTokens({ email, status }, cond(!isPaginationDisabled && pagination)),
]);
ctx.pagination.totalCount = count;
ctx.body = oneTimeTokens;
ctx.status = 200;
return next();
}
);
router.get(
'/one-time-tokens/:id',
koaGuard({

View file

@ -33,3 +33,6 @@ export const updateOneTimeTokenStatus = async (id: string, status: OneTimeTokenS
export const deleteOneTimeTokenById = async (id: string) =>
authedAdminApi.delete(`one-time-tokens/${id}`).json<OneTimeToken>();
export const getOneTimeTokens = async (searchParams?: Record<string, string>) =>
authedAdminApi.get('one-time-tokens', { searchParams }).json<OneTimeToken[]>();

View file

@ -1,12 +1,14 @@
import { OneTimeTokenStatus } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { authedAdminApi } from '#src/api/api.js';
import {
createOneTimeToken,
verifyOneTimeToken,
getOneTimeTokenById,
updateOneTimeTokenStatus,
deleteOneTimeTokenById,
getOneTimeTokens,
} from '#src/api/one-time-token.js';
import { expectRejects } from '#src/helpers/index.js';
import { devFeatureTest, waitFor } from '#src/utils.js';
@ -295,4 +297,54 @@ describe('one-time tokens API', () => {
status: 404,
});
});
it('should be able to batch fetch one-time tokens', async () => {
const email = `foo${generateStandardId()}@bar.com`;
const oneTimeToken1 = await createOneTimeToken({ email });
const oneTimeToken2 = await createOneTimeToken({ email });
const oneTimeToken3 = await createOneTimeToken({ email });
const tokens = await getOneTimeTokens();
expect(tokens).toEqual(expect.arrayContaining([oneTimeToken1, oneTimeToken2, oneTimeToken3]));
const response = await authedAdminApi.get('one-time-tokens?page=1&page_size=2');
expect(response.headers.get('Total-Number')).toBe('3');
expect(await response.json()).toEqual(expect.arrayContaining([oneTimeToken2, oneTimeToken3]));
void deleteOneTimeTokenById(oneTimeToken1.id);
void deleteOneTimeTokenById(oneTimeToken2.id);
void deleteOneTimeTokenById(oneTimeToken3.id);
});
it('should be able fetch list of one-time tokens with query params', async () => {
const fooEmail = `foo${generateStandardId()}@bar.com`;
const barEmail = `bar${generateStandardId()}@bar.com`;
const [oneTimeToken1, oneTimeToken2, oneTimeToken3] = await Promise.all([
createOneTimeToken({ email: fooEmail }),
createOneTimeToken({ email: fooEmail }),
createOneTimeToken({ email: barEmail }),
]);
const tokens = await getOneTimeTokens({ email: fooEmail });
expect(tokens).toEqual(expect.arrayContaining([oneTimeToken1, oneTimeToken2]));
await updateOneTimeTokenStatus(oneTimeToken1.id, OneTimeTokenStatus.Revoked);
await verifyOneTimeToken({ email: fooEmail, token: oneTimeToken2.token });
const revokedTokens = await getOneTimeTokens({ status: OneTimeTokenStatus.Revoked });
expect(revokedTokens).toEqual([{ ...oneTimeToken1, status: OneTimeTokenStatus.Revoked }]);
const consumedTokens = await getOneTimeTokens({ status: OneTimeTokenStatus.Consumed });
expect(consumedTokens).toEqual([{ ...oneTimeToken2, status: OneTimeTokenStatus.Consumed }]);
const activeBarTokens = await getOneTimeTokens({
email: barEmail,
status: OneTimeTokenStatus.Active,
});
expect(activeBarTokens).toEqual([oneTimeToken3]);
void deleteOneTimeTokenById(oneTimeToken1.id);
void deleteOneTimeTokenById(oneTimeToken2.id);
void deleteOneTimeTokenById(oneTimeToken3.id);
});
});