0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -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",
"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",

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 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 = () => {
/>
</TableEmpty>
)}
{users?.map(({ id, name, username }) => (
{users?.map(({ id, name, username, lastSignInAt }) => (
<tr
key={id}
className={tableStyles.clickable}
@ -130,7 +131,9 @@ const Users = () => {
/>
</td>
<td>Application</td>
<td>Last sign in</td>
<td>
<DateTime>{lastSignInAt}</DateTime>
</td>
</tr>
))}
</tbody>

View file

@ -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,
},
];

View file

@ -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() })
);
});
});

View file

@ -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() });

View file

@ -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]);
});

View file

@ -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'),

View file

@ -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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(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<T extends AnonymousRouter>(router: T, prov
});
ctx.log(type, { userId: id });
await updateLastSignInAt(id);
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
return next();

View file

@ -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<CreateUser> = z.object({
@ -60,6 +62,7 @@ const createGuard: Guard<CreateUser> = z.object({
roleNames: roleNamesGuard.optional(),
identities: identitiesGuard.optional(),
customData: arbitraryObjectGuard.optional(),
lastSignInAt: z.number().nullable().optional(),
});
export const Users: GeneratedSchema<CreateUser> = Object.freeze({
@ -79,6 +82,7 @@ export const Users: GeneratedSchema<CreateUser> = 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<CreateUser> = Object.freeze({
'roleNames',
'identities',
'customData',
'lastSignInAt',
],
createGuard,
});

View file

@ -10,6 +10,7 @@ export const userInfoSelectFields = Object.freeze([
'roleNames',
'customData',
'identities',
'lastSignInAt',
] as const);
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,
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)
);

View file

@ -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