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:
parent
0ecb7e4d2f
commit
a0b4b98c35
13 changed files with 86 additions and 10 deletions
|
@ -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",
|
||||
|
|
19
packages/console/src/components/DateTime/index.tsx
Normal file
19
packages/console/src/components/DateTime/index.tsx
Normal 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;
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -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() })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() });
|
||||
|
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue