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:
parent
0ecb7e4d2f
commit
a0b4b98c35
13 changed files with 86 additions and 10 deletions
|
@ -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",
|
||||||
|
|
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 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>
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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() })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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() });
|
||||||
|
|
|
@ -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]);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
@ -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<
|
||||||
|
|
|
@ -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
2
pnpm-lock.yaml
generated
|
@ -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
|
||||||
|
|
Loading…
Add table
Reference in a new issue