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:
parent
97d713aaec
commit
387cdfdb9a
5 changed files with 142 additions and 4 deletions
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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[]>();
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue