0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

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 <icehe@silverhand.io>
This commit is contained in:
Wang Sijie 2022-05-05 16:22:43 +08:00 committed by GitHub
parent 0ecb7e4d2f
commit a0b4b98c35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 86 additions and 10 deletions

View file

@ -41,6 +41,7 @@
"classnames": "^2.3.1", "classnames": "^2.3.1",
"copyfiles": "^2.4.1", "copyfiles": "^2.4.1",
"csstype": "^3.0.11", "csstype": "^3.0.11",
"dayjs": "^1.10.5",
"dnd-core": "^16.0.0", "dnd-core": "^16.0.0",
"eslint": "^8.10.0", "eslint": "^8.10.0",
"i18next": "^21.6.12", "i18next": "^21.6.12",

View file

@ -0,0 +1,19 @@
import { Nullable } from '@silverhand/essentials';
import dayjs from 'dayjs';
import React from 'react';
type Props = {
children: Nullable<string | number>;
};
const DateTime = ({ children }: Props) => {
const date = dayjs(children);
if (!children || !date.isValid()) {
return <span>-</span>;
}
return <span>{date.toDate().toLocaleDateString()}</span>;
};
export default DateTime;

View file

@ -10,6 +10,7 @@ import useSWR from 'swr';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Card from '@/components/Card'; import Card from '@/components/Card';
import CardTitle from '@/components/CardTitle'; import CardTitle from '@/components/CardTitle';
import DateTime from '@/components/DateTime';
import ImagePlaceholder from '@/components/ImagePlaceholder'; import ImagePlaceholder from '@/components/ImagePlaceholder';
import ItemPreview from '@/components/ItemPreview'; import ItemPreview from '@/components/ItemPreview';
import Pagination from '@/components/Pagination'; import Pagination from '@/components/Pagination';
@ -112,7 +113,7 @@ const Users = () => {
/> />
</TableEmpty> </TableEmpty>
)} )}
{users?.map(({ id, name, username }) => ( {users?.map(({ id, name, username, lastSignInAt }) => (
<tr <tr
key={id} key={id}
className={tableStyles.clickable} className={tableStyles.clickable}
@ -130,7 +131,9 @@ const Users = () => {
/> />
</td> </td>
<td>Application</td> <td>Application</td>
<td>Last sign in</td> <td>
<DateTime>{lastSignInAt}</DateTime>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View file

@ -17,6 +17,7 @@ export const mockUser: User = {
}, },
customData: {}, customData: {},
applicationId: 'bar', applicationId: 'bar',
lastSignInAt: 1_650_969_465_789,
}; };
export const mockUserResponse = pick(mockUser, ...userInfoSelectFields); export const mockUserResponse = pick(mockUser, ...userInfoSelectFields);
@ -36,6 +37,7 @@ export const mockUserList: User[] = [
identities: {}, identities: {},
customData: {}, customData: {},
applicationId: 'bar', applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
}, },
{ {
id: '2', id: '2',
@ -51,6 +53,7 @@ export const mockUserList: User[] = [
identities: {}, identities: {},
customData: {}, customData: {},
applicationId: 'bar', applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
}, },
{ {
id: '3', id: '3',
@ -66,6 +69,7 @@ export const mockUserList: User[] = [
identities: {}, identities: {},
customData: {}, customData: {},
applicationId: 'bar', applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
}, },
{ {
id: '4', id: '4',
@ -81,6 +85,7 @@ export const mockUserList: User[] = [
identities: {}, identities: {},
customData: {}, customData: {},
applicationId: 'bar', applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
}, },
{ {
id: '5', id: '5',
@ -96,6 +101,7 @@ export const mockUserList: User[] = [
identities: {}, identities: {},
customData: {}, customData: {},
applicationId: 'bar', applicationId: 'bar',
lastSignInAt: 1_650_969_465_000,
}, },
]; ];

View file

@ -1,8 +1,8 @@
import { UsersPasswordEncryptionMethod } from '@logto/schemas'; 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'); jest.mock('@/queries/user');
@ -61,3 +61,17 @@ describe('encryptUserPassword()', () => {
expect(passwordEncryptionSalt).toHaveLength(21); 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() })
);
});
});

View file

@ -1,8 +1,8 @@
import { UsersPasswordEncryptionMethod, User } from '@logto/schemas'; import { User, UsersPasswordEncryptionMethod } from '@logto/schemas';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import pRetry from 'p-retry'; 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 assertThat from '@/utils/assert-that';
import { buildIdGenerator } from '@/utils/id'; import { buildIdGenerator } from '@/utils/id';
import { encryptPassword } from '@/utils/password'; import { encryptPassword } from '@/utils/password';
@ -63,3 +63,6 @@ export const findUserByUsernameAndPassword = async (
return user; return user;
}; };
export const updateLastSignInAt = async (userId: string) =>
updateUserById(userId, { lastSignInAt: Date.now() });

View file

@ -240,16 +240,22 @@ describe('user query', () => {
const expectSql = sql` const expectSql = sql`
insert into ${table} (${sql.join(Object.values(fields), sql`, `)}) insert into ${table} (${sql.join(Object.values(fields), sql`, `)})
values (${sql.join( values (${sql.join(
Object.values(fields).map((_, index) => `$${index + 1}`), Object.values(fields)
.slice(0, -1)
.map((_, index) => `$${index + 1}`),
sql`, ` sql`, `
)}) )}, to_timestamp(${Object.values(fields).length}::double precision / 1000))
returning * returning *
`; `;
mockQuery.mockImplementationOnce(async (sql, values) => { mockQuery.mockImplementationOnce(async (sql, values) => {
expectSqlAssert(sql, expectSql.sql); 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]); return createMockQueryResult([dbvalue]);
}); });

View file

@ -28,6 +28,7 @@ jest.mock('@/lib/user', () => ({
passwordEncryptionMethod: 'SaltAndPepper', passwordEncryptionMethod: 'SaltAndPepper',
passwordEncryptionSalt: 'user1', passwordEncryptionSalt: 'user1',
}), }),
updateLastSignInAt: async (...args: unknown[]) => updateUserById(...args),
})); }));
jest.mock('@/lib/social', () => ({ jest.mock('@/lib/social', () => ({
...jest.requireActual('@/lib/social'), ...jest.requireActual('@/lib/social'),

View file

@ -17,7 +17,12 @@ import {
getUserInfoByAuthCode, getUserInfoByAuthCode,
getUserInfoFromInteractionResult, getUserInfoFromInteractionResult,
} from '@/lib/social'; } 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 koaGuard from '@/middleware/koa-guard';
import { findDefaultSignInExperience } from '@/queries/sign-in-experience'; import { findDefaultSignInExperience } from '@/queries/sign-in-experience';
import { import {
@ -75,6 +80,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
const { id } = await findUserByUsernameAndPassword(username, password); const { id } = await findUserByUsernameAndPassword(username, password);
ctx.log(type, { userId: id }); ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -123,6 +129,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
const { id } = await findUserByPhone(phone); const { id } = await findUserByPhone(phone);
ctx.log(type, { userId: id }); ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -171,6 +178,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
const { id } = await findUserByEmail(email); const { id } = await findUserByEmail(email);
ctx.log(type, { userId: id }); ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -225,6 +233,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
await updateUserById(id, { await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
}); });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -256,6 +265,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
await updateUserById(id, { await updateUserById(id, {
identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } }, identities: { ...identities, [connectorId]: { userId: userInfo.id, details: userInfo } },
}); });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -346,6 +356,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
passwordEncryptionMethod, passwordEncryptionMethod,
passwordEncryptionSalt, passwordEncryptionSalt,
}); });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -407,6 +418,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
ctx.log(type, { userId: id }); ctx.log(type, { userId: id });
await insertUser({ id, primaryPhone: phone }); await insertUser({ id, primaryPhone: phone });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -456,6 +468,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
ctx.log(type, { userId: id }); ctx.log(type, { userId: id });
await insertUser({ id, primaryEmail: email }); await insertUser({ id, primaryEmail: email });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();
@ -498,6 +511,7 @@ export default function sessionRoutes<T extends AnonymousRouter>(router: T, prov
}); });
ctx.log(type, { userId: id }); ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } }); await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next(); return next();

View file

@ -28,6 +28,7 @@ export type CreateUser = {
roleNames?: RoleNames; roleNames?: RoleNames;
identities?: Identities; identities?: Identities;
customData?: ArbitraryObject; customData?: ArbitraryObject;
lastSignInAt?: number | null;
}; };
export type User = { export type User = {
@ -44,6 +45,7 @@ export type User = {
roleNames: RoleNames; roleNames: RoleNames;
identities: Identities; identities: Identities;
customData: ArbitraryObject; customData: ArbitraryObject;
lastSignInAt: number | null;
}; };
const createGuard: Guard<CreateUser> = z.object({ const createGuard: Guard<CreateUser> = z.object({
@ -60,6 +62,7 @@ const createGuard: Guard<CreateUser> = z.object({
roleNames: roleNamesGuard.optional(), roleNames: roleNamesGuard.optional(),
identities: identitiesGuard.optional(), identities: identitiesGuard.optional(),
customData: arbitraryObjectGuard.optional(), customData: arbitraryObjectGuard.optional(),
lastSignInAt: z.number().nullable().optional(),
}); });
export const Users: GeneratedSchema<CreateUser> = Object.freeze({ export const Users: GeneratedSchema<CreateUser> = Object.freeze({
@ -79,6 +82,7 @@ export const Users: GeneratedSchema<CreateUser> = Object.freeze({
roleNames: 'role_names', roleNames: 'role_names',
identities: 'identities', identities: 'identities',
customData: 'custom_data', customData: 'custom_data',
lastSignInAt: 'last_sign_in_at',
}, },
fieldKeys: [ fieldKeys: [
'id', 'id',
@ -94,6 +98,7 @@ export const Users: GeneratedSchema<CreateUser> = Object.freeze({
'roleNames', 'roleNames',
'identities', 'identities',
'customData', 'customData',
'lastSignInAt',
], ],
createGuard, createGuard,
}); });

View file

@ -10,6 +10,7 @@ export const userInfoSelectFields = Object.freeze([
'roleNames', 'roleNames',
'customData', 'customData',
'identities', 'identities',
'lastSignInAt',
] as const); ] as const);
export type UserInfo<Keys extends keyof CreateUser = typeof userInfoSelectFields[number]> = Pick< export type UserInfo<Keys extends keyof CreateUser = typeof userInfoSelectFields[number]> = Pick<

View file

@ -14,5 +14,6 @@ create table users (
role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb, role_names jsonb /* @use RoleNames */ not null default '[]'::jsonb,
identities jsonb /* @use Identities */ not null default '{}'::jsonb, identities jsonb /* @use Identities */ not null default '{}'::jsonb,
custom_data jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb, custom_data jsonb /* @use ArbitraryObject */ not null default '{}'::jsonb,
last_sign_in_at timestamptz,
primary key (id) primary key (id)
); );

2
pnpm-lock.yaml generated
View file

@ -458,6 +458,7 @@ importers:
classnames: ^2.3.1 classnames: ^2.3.1
copyfiles: ^2.4.1 copyfiles: ^2.4.1
csstype: ^3.0.11 csstype: ^3.0.11
dayjs: ^1.10.5
dnd-core: ^16.0.0 dnd-core: ^16.0.0
eslint: ^8.10.0 eslint: ^8.10.0
i18next: ^21.6.12 i18next: ^21.6.12
@ -511,6 +512,7 @@ importers:
classnames: 2.3.1 classnames: 2.3.1
copyfiles: 2.4.1 copyfiles: 2.4.1
csstype: 3.0.11 csstype: 3.0.11
dayjs: 1.10.7
dnd-core: 16.0.0 dnd-core: 16.0.0
eslint: 8.10.0 eslint: 8.10.0
i18next: 21.6.12 i18next: 21.6.12