From a0b4b98c35ff08c2df0863e4bc2110386fc54aee Mon Sep 17 00:00:00 2001 From: Wang Sijie Date: Thu, 5 May 2022 16:22:43 +0800 Subject: [PATCH] feat(console): add column lastSignIn in user management (#679) * feat(core): user last sign in * fix(core): rename last_sign_in to last_sign_in_at for updating its timestamptz value * fix: rename column name in frontend * fix: test * fix: toLocaleDateString Co-authored-by: IceHe.xyz --- packages/console/package.json | 1 + .../console/src/components/DateTime/index.tsx | 19 +++++++++++++++++++ packages/console/src/pages/Users/index.tsx | 7 +++++-- packages/core/src/__mocks__/user.ts | 6 ++++++ packages/core/src/lib/user.test.ts | 18 ++++++++++++++++-- packages/core/src/lib/user.ts | 7 +++++-- packages/core/src/queries/user.test.ts | 12 +++++++++--- packages/core/src/routes/session.test.ts | 1 + packages/core/src/routes/session.ts | 16 +++++++++++++++- packages/schemas/src/db-entries/user.ts | 5 +++++ packages/schemas/src/types/user.ts | 1 + packages/schemas/tables/users.sql | 1 + pnpm-lock.yaml | 2 ++ 13 files changed, 86 insertions(+), 10 deletions(-) create mode 100644 packages/console/src/components/DateTime/index.tsx diff --git a/packages/console/package.json b/packages/console/package.json index 338219ee0..a2e3226cb 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -41,6 +41,7 @@ "classnames": "^2.3.1", "copyfiles": "^2.4.1", "csstype": "^3.0.11", + "dayjs": "^1.10.5", "dnd-core": "^16.0.0", "eslint": "^8.10.0", "i18next": "^21.6.12", diff --git a/packages/console/src/components/DateTime/index.tsx b/packages/console/src/components/DateTime/index.tsx new file mode 100644 index 000000000..1212c8496 --- /dev/null +++ b/packages/console/src/components/DateTime/index.tsx @@ -0,0 +1,19 @@ +import { Nullable } from '@silverhand/essentials'; +import dayjs from 'dayjs'; +import React from 'react'; + +type Props = { + children: Nullable; +}; + +const DateTime = ({ children }: Props) => { + const date = dayjs(children); + + if (!children || !date.isValid()) { + return -; + } + + return {date.toDate().toLocaleDateString()}; +}; + +export default DateTime; diff --git a/packages/console/src/pages/Users/index.tsx b/packages/console/src/pages/Users/index.tsx index 3936f0acf..f95a4c1bc 100644 --- a/packages/console/src/pages/Users/index.tsx +++ b/packages/console/src/pages/Users/index.tsx @@ -10,6 +10,7 @@ import useSWR from 'swr'; import Button from '@/components/Button'; import Card from '@/components/Card'; import CardTitle from '@/components/CardTitle'; +import DateTime from '@/components/DateTime'; import ImagePlaceholder from '@/components/ImagePlaceholder'; import ItemPreview from '@/components/ItemPreview'; import Pagination from '@/components/Pagination'; @@ -112,7 +113,7 @@ const Users = () => { /> )} - {users?.map(({ id, name, username }) => ( + {users?.map(({ id, name, username, lastSignInAt }) => ( { /> Application - Last sign in + + {lastSignInAt} + ))} diff --git a/packages/core/src/__mocks__/user.ts b/packages/core/src/__mocks__/user.ts index b7ccf7e7e..668bc7654 100644 --- a/packages/core/src/__mocks__/user.ts +++ b/packages/core/src/__mocks__/user.ts @@ -17,6 +17,7 @@ export const mockUser: User = { }, customData: {}, applicationId: 'bar', + lastSignInAt: 1_650_969_465_789, }; export const mockUserResponse = pick(mockUser, ...userInfoSelectFields); @@ -36,6 +37,7 @@ export const mockUserList: User[] = [ identities: {}, customData: {}, applicationId: 'bar', + lastSignInAt: 1_650_969_465_000, }, { id: '2', @@ -51,6 +53,7 @@ export const mockUserList: User[] = [ identities: {}, customData: {}, applicationId: 'bar', + lastSignInAt: 1_650_969_465_000, }, { id: '3', @@ -66,6 +69,7 @@ export const mockUserList: User[] = [ identities: {}, customData: {}, applicationId: 'bar', + lastSignInAt: 1_650_969_465_000, }, { id: '4', @@ -81,6 +85,7 @@ export const mockUserList: User[] = [ identities: {}, customData: {}, applicationId: 'bar', + lastSignInAt: 1_650_969_465_000, }, { id: '5', @@ -96,6 +101,7 @@ export const mockUserList: User[] = [ identities: {}, customData: {}, applicationId: 'bar', + lastSignInAt: 1_650_969_465_000, }, ]; diff --git a/packages/core/src/lib/user.test.ts b/packages/core/src/lib/user.test.ts index 34723dd5a..051e144ac 100644 --- a/packages/core/src/lib/user.test.ts +++ b/packages/core/src/lib/user.test.ts @@ -1,8 +1,8 @@ import { UsersPasswordEncryptionMethod } from '@logto/schemas'; -import { hasUserWithId } from '@/queries/user'; +import { hasUserWithId, updateUserById } from '@/queries/user'; -import { encryptUserPassword, generateUserId } from './user'; +import { encryptUserPassword, generateUserId, updateLastSignInAt } from './user'; jest.mock('@/queries/user'); @@ -61,3 +61,17 @@ describe('encryptUserPassword()', () => { expect(passwordEncryptionSalt).toHaveLength(21); }); }); + +describe('updateLastSignIn()', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-01-01')); + }); + + it('calls updateUserById with current timestamp', async () => { + await updateLastSignInAt('user-id'); + expect(updateUserById).toHaveBeenCalledWith( + 'user-id', + expect.objectContaining({ lastSignInAt: new Date('2020-01-01').getTime() }) + ); + }); +}); diff --git a/packages/core/src/lib/user.ts b/packages/core/src/lib/user.ts index 3b5349378..aa490bba5 100644 --- a/packages/core/src/lib/user.ts +++ b/packages/core/src/lib/user.ts @@ -1,8 +1,8 @@ -import { UsersPasswordEncryptionMethod, User } from '@logto/schemas'; +import { User, UsersPasswordEncryptionMethod } from '@logto/schemas'; import { nanoid } from 'nanoid'; import pRetry from 'p-retry'; -import { findUserByUsername, hasUserWithId } from '@/queries/user'; +import { findUserByUsername, hasUserWithId, updateUserById } from '@/queries/user'; import assertThat from '@/utils/assert-that'; import { buildIdGenerator } from '@/utils/id'; import { encryptPassword } from '@/utils/password'; @@ -63,3 +63,6 @@ export const findUserByUsernameAndPassword = async ( return user; }; + +export const updateLastSignInAt = async (userId: string) => + updateUserById(userId, { lastSignInAt: Date.now() }); diff --git a/packages/core/src/queries/user.test.ts b/packages/core/src/queries/user.test.ts index a0e988955..1c0331bcf 100644 --- a/packages/core/src/queries/user.test.ts +++ b/packages/core/src/queries/user.test.ts @@ -240,16 +240,22 @@ describe('user query', () => { const expectSql = sql` insert into ${table} (${sql.join(Object.values(fields), sql`, `)}) values (${sql.join( - Object.values(fields).map((_, index) => `$${index + 1}`), + Object.values(fields) + .slice(0, -1) + .map((_, index) => `$${index + 1}`), sql`, ` - )}) + )}, to_timestamp(${Object.values(fields).length}::double precision / 1000)) returning * `; mockQuery.mockImplementationOnce(async (sql, values) => { expectSqlAssert(sql, expectSql.sql); - expect(values).toEqual(Users.fieldKeys.map((k) => convertToPrimitiveOrSql(k, mockUser[k]))); + expect(values).toEqual( + Users.fieldKeys.map((k) => + k === 'lastSignInAt' ? mockUser[k] : convertToPrimitiveOrSql(k, mockUser[k]) + ) + ); return createMockQueryResult([dbvalue]); }); diff --git a/packages/core/src/routes/session.test.ts b/packages/core/src/routes/session.test.ts index e0950a305..3cc189a24 100644 --- a/packages/core/src/routes/session.test.ts +++ b/packages/core/src/routes/session.test.ts @@ -28,6 +28,7 @@ jest.mock('@/lib/user', () => ({ passwordEncryptionMethod: 'SaltAndPepper', passwordEncryptionSalt: 'user1', }), + updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args), })); jest.mock('@/lib/social', () => ({ ...jest.requireActual('@/lib/social'), diff --git a/packages/core/src/routes/session.ts b/packages/core/src/routes/session.ts index 41c33b8db..49f0b528e 100644 --- a/packages/core/src/routes/session.ts +++ b/packages/core/src/routes/session.ts @@ -17,7 +17,12 @@ import { getUserInfoByAuthCode, getUserInfoFromInteractionResult, } from '@/lib/social'; -import { encryptUserPassword, generateUserId, findUserByUsernameAndPassword } from '@/lib/user'; +import { + encryptUserPassword, + generateUserId, + findUserByUsernameAndPassword, + updateLastSignInAt, +} from '@/lib/user'; import koaGuard from '@/middleware/koa-guard'; import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; import { @@ -75,6 +80,7 @@ export default function sessionRoutes(router: T, prov const { id } = await findUserByUsernameAndPassword(username, password); ctx.log(type, { userId: id }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -123,6 +129,7 @@ export default function sessionRoutes(router: T, prov const { id } = await findUserByPhone(phone); ctx.log(type, { userId: id }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -171,6 +178,7 @@ export default function sessionRoutes(router: T, prov const { id } = await findUserByEmail(email); ctx.log(type, { userId: id }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -225,6 +233,7 @@ export default function sessionRoutes(router: T, prov await updateUserById(id, { identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -256,6 +265,7 @@ export default function sessionRoutes(router: T, prov await updateUserById(id, { identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -346,6 +356,7 @@ export default function sessionRoutes(router: T, prov passwordEncryptionMethod, passwordEncryptionSalt, }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -407,6 +418,7 @@ export default function sessionRoutes(router: T, prov ctx.log(type, { userId: id }); await insertUser({ id, primaryPhone: phone }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -456,6 +468,7 @@ export default function sessionRoutes(router: T, prov ctx.log(type, { userId: id }); await insertUser({ id, primaryEmail: email }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); @@ -498,6 +511,7 @@ export default function sessionRoutes(router: T, prov }); ctx.log(type, { userId: id }); + await updateLastSignInAt(id); await assignInteractionResults(ctx, provider, { login: { accountId: id } }); return next(); diff --git a/packages/schemas/src/db-entries/user.ts b/packages/schemas/src/db-entries/user.ts index 056eea236..2c3f1120f 100644 --- a/packages/schemas/src/db-entries/user.ts +++ b/packages/schemas/src/db-entries/user.ts @@ -28,6 +28,7 @@ export type CreateUser = { roleNames?: RoleNames; identities?: Identities; customData?: ArbitraryObject; + lastSignInAt?: number | null; }; export type User = { @@ -44,6 +45,7 @@ export type User = { roleNames: RoleNames; identities: Identities; customData: ArbitraryObject; + lastSignInAt: number | null; }; const createGuard: Guard = z.object({ @@ -60,6 +62,7 @@ const createGuard: Guard = z.object({ roleNames: roleNamesGuard.optional(), identities: identitiesGuard.optional(), customData: arbitraryObjectGuard.optional(), + lastSignInAt: z.number().nullable().optional(), }); export const Users: GeneratedSchema = Object.freeze({ @@ -79,6 +82,7 @@ export const Users: GeneratedSchema = Object.freeze({ roleNames: 'role_names', identities: 'identities', customData: 'custom_data', + lastSignInAt: 'last_sign_in_at', }, fieldKeys: [ 'id', @@ -94,6 +98,7 @@ export const Users: GeneratedSchema = Object.freeze({ 'roleNames', 'identities', 'customData', + 'lastSignInAt', ], createGuard, }); diff --git a/packages/schemas/src/types/user.ts b/packages/schemas/src/types/user.ts index 1d3622a90..5a0e2946c 100644 --- a/packages/schemas/src/types/user.ts +++ b/packages/schemas/src/types/user.ts @@ -10,6 +10,7 @@ export const userInfoSelectFields = Object.freeze([ 'roleNames', 'customData', 'identities', + 'lastSignInAt', ] as const); export type UserInfo = Pick< diff --git a/packages/schemas/tables/users.sql b/packages/schemas/tables/users.sql index 37bf3fb2a..ad68cf7e4 100644 --- a/packages/schemas/tables/users.sql +++ b/packages/schemas/tables/users.sql @@ -14,5 +14,6 @@ create table users ( role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb, identities jsonb /* @use Identities */ not null default '{}'::jsonb, custom_data jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb, + last_sign_in_at timestamptz, primary key (id) ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50ea18ac1..48e728e00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -458,6 +458,7 @@ importers: classnames: ^2.3.1 copyfiles: ^2.4.1 csstype: ^3.0.11 + dayjs: ^1.10.5 dnd-core: ^16.0.0 eslint: ^8.10.0 i18next: ^21.6.12 @@ -511,6 +512,7 @@ importers: classnames: 2.3.1 copyfiles: 2.4.1 csstype: 3.0.11 + dayjs: 1.10.7 dnd-core: 16.0.0 eslint: 8.10.0 i18next: 21.6.12