0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat: add profile api, update api and database, add tests

This commit is contained in:
Gao Sun 2024-03-20 13:16:23 +08:00
parent 1bc40faf98
commit 6feb531435
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
8 changed files with 115 additions and 3 deletions

View file

@ -115,6 +115,15 @@ export const getUserClaimsData = async (
}
default: {
if (isUserProfileClaim(claim)) {
// Unlike other database fields (e.g. `name`), the claims stored in the `profile` field
// will fall back to `undefined` rather than `null`. We refrain from using `?? null`
// here to reduce the size of ID tokens, since `undefined` fields will be stripped in
// tokens.
//
// The only consideration here is the inconsistency for `name` and `picture`, which are
// also standard claims but they will fall back to `null`. While it's possible to align
// their behavior by having their fallback to `undefined`, it's better to maintain the
// current setup for now to prevent breaking changes.
return [claim, user.profile[claimToUserProfileKey[claim]]];
}

View file

@ -3,6 +3,7 @@ import {
UsersPasswordEncryptionMethod,
jsonObjectGuard,
userInfoSelectFields,
userProfileGuard,
userProfileResponseGuard,
} from '@logto/schemas';
import { conditional, pick, yes } from '@silverhand/essentials';
@ -108,6 +109,32 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
}
);
router.patch(
'/users/:userId/profile',
koaGuard({
params: object({ userId: string() }),
body: object({ profile: userProfileGuard }),
response: userProfileGuard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { userId },
body: { profile },
} = ctx.guard;
await findUserById(userId);
const user = await updateUserById(userId, {
profile,
});
ctx.body = user.profile;
return next();
}
);
router.post(
'/users',
koaGuard({
@ -121,6 +148,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
name: string(),
avatar: string().url().or(literal('')).nullable(),
customData: jsonObjectGuard,
profile: userProfileGuard,
}).partial(),
response: userProfileResponseGuard,
status: [200, 404, 422],
@ -136,6 +164,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
passwordAlgorithm,
avatar,
customData,
profile,
} = ctx.guard.body;
assertThat(!(password && passwordDigest), new RequestError('user.password_and_digest'));
@ -178,6 +207,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
passwordEncryptionMethod: passwordAlgorithm,
}
),
...conditional(profile && { profile }),
},
[]
);
@ -199,6 +229,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
name: string().or(literal('')).nullable(),
avatar: string().url().or(literal('')).nullable(),
customData: jsonObjectGuard,
profile: userProfileGuard,
}).partial(),
response: userProfileResponseGuard,
status: [200, 404, 422],
@ -317,6 +348,7 @@ export default function adminUserBasicsRoutes<T extends AuthedRouter>(...args: R
return next();
}
// eslint-disable-next-line max-lines
);
router.delete(

View file

@ -48,6 +48,13 @@ export const updateUser = async (userId: string, payload: Partial<User>) =>
})
.json<User>();
export const updateUserProfile = async (userId: string, profile: Partial<User['profile']>) =>
authedAdminApi
.patch(`users/${userId}/profile`, {
json: { profile },
})
.json<User['profile']>();
export const suspendUser = async (userId: string, isSuspended: boolean) =>
authedAdminApi.patch(`users/${userId}/is-suspended`, { json: { isSuspended } }).json<User>();

View file

@ -2,7 +2,11 @@ import fs from 'node:fs/promises';
import { createServer, type RequestListener } from 'node:http';
import { mockConnectorFilePaths, type SendMessagePayload } from '@logto/connector-kit';
import { type JsonObject, type UsersPasswordEncryptionMethod } from '@logto/schemas';
import {
type UserProfile,
type JsonObject,
type UsersPasswordEncryptionMethod,
} from '@logto/schemas';
import { RequestError } from 'got';
import { createUser } from '#src/api/index.js';
@ -18,6 +22,7 @@ export const createUserByAdmin = async (
passwordDigest?: string;
passwordAlgorithm?: UsersPasswordEncryptionMethod;
customData?: JsonObject;
profile?: UserProfile;
} = {}
) => {
const { username, name, ...rest } = payload;

View file

@ -18,6 +18,7 @@ import {
postUserIdentity,
verifyUserPassword,
putUserIdentity,
updateUserProfile,
} from '#src/api/index.js';
import { createUserByAdmin, expectRejects } from '#src/helpers/index.js';
import { createNewSocialUserWithUsernameAndPassword } from '#src/helpers/interactions.js';
@ -46,12 +47,14 @@ describe('admin console user management', () => {
await expect(verifyUserPassword(user.id, 'password')).resolves.not.toThrow();
});
it('should create user with custom data successfully', async () => {
it('should create user with custom data and profile successfully', async () => {
const user = await createUserByAdmin({
customData: { foo: 'bar' },
profile: { gender: 'neutral' },
});
const { customData } = await getUser(user.id);
const { customData, profile } = await getUser(user.id);
expect(customData).toStrictEqual({ foo: 'bar' });
expect(profile).toStrictEqual({ gender: 'neutral' });
});
it('should fail when create user with conflict identifiers', async () => {
@ -95,11 +98,38 @@ describe('admin console user management', () => {
customData: {
level: 1,
},
profile: {
familyName: 'new family name',
address: {
formatted: 'new formatted address',
},
},
};
const updatedUser = await updateUser(user.id, newUserData);
expect(updatedUser).toMatchObject(newUserData);
expect(updatedUser.updatedAt).toBeGreaterThan(user.updatedAt);
});
it('should able to update profile partially', async () => {
const user = await createUserByAdmin();
const profile = {
familyName: 'new family name',
address: {
formatted: 'new formatted address',
},
};
const updatedProfile = await updateUserProfile(user.id, profile);
expect(updatedProfile).toStrictEqual(profile);
const patchProfile = {
familyName: 'another name',
website: 'https://logto.io/',
};
const updatedProfile2 = await updateUserProfile(user.id, patchProfile);
expect(updatedProfile2).toStrictEqual({ ...profile, ...patchProfile });
});
it('should respond 422 when no update data provided', async () => {

View file

@ -9,6 +9,18 @@ const alteration: AlterationScript = {
add column profile jsonb not null default '{}'::jsonb,
add column updated_at timestamptz not null default (now());
`);
await pool.query(sql`
create function set_updated_at() returns trigger as
$$ begin
new.updated_at = now();
return new;
end; $$ language plpgsql;
create trigger set_updated_at
before update on users
for each row
execute procedure set_updated_at();
`);
},
down: async (pool) => {
await pool.query(sql`
@ -16,6 +28,10 @@ const alteration: AlterationScript = {
drop column profile,
drop column updated_at;
`);
await pool.query(sql`
drop trigger set_updated_at on users;
drop function set_updated_at();
`);
},
};

View file

@ -1,5 +1,6 @@
/* init_order = 0.5 */
/** A function to set the tenant_id column based on the current user. */
create function set_tenant_id() returns trigger as
$$ begin
if new.tenant_id is not null then
@ -13,4 +14,11 @@ $$ begin
return new;
end; $$ language plpgsql;
/** A function to set the created_at column to the current time. */
create function set_updated_at() returns trigger as
$$ begin
new.updated_at = now();
return new;
end; $$ language plpgsql;
/* no_after_each */

View file

@ -39,3 +39,8 @@ create index users__id
create index users__name
on users (tenant_id, name);
create trigger set_updated_at
before update on users
for each row
execute procedure set_updated_at();