0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -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: { default: {
if (isUserProfileClaim(claim)) { 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]]]; return [claim, user.profile[claimToUserProfileKey[claim]]];
} }

View file

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

View file

@ -48,6 +48,13 @@ export const updateUser = async (userId: string, payload: Partial<User>) =>
}) })
.json<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) => export const suspendUser = async (userId: string, isSuspended: boolean) =>
authedAdminApi.patch(`users/${userId}/is-suspended`, { json: { isSuspended } }).json<User>(); 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 { createServer, type RequestListener } from 'node:http';
import { mockConnectorFilePaths, type SendMessagePayload } from '@logto/connector-kit'; 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 { RequestError } from 'got';
import { createUser } from '#src/api/index.js'; import { createUser } from '#src/api/index.js';
@ -18,6 +22,7 @@ export const createUserByAdmin = async (
passwordDigest?: string; passwordDigest?: string;
passwordAlgorithm?: UsersPasswordEncryptionMethod; passwordAlgorithm?: UsersPasswordEncryptionMethod;
customData?: JsonObject; customData?: JsonObject;
profile?: UserProfile;
} = {} } = {}
) => { ) => {
const { username, name, ...rest } = payload; const { username, name, ...rest } = payload;

View file

@ -18,6 +18,7 @@ import {
postUserIdentity, postUserIdentity,
verifyUserPassword, verifyUserPassword,
putUserIdentity, putUserIdentity,
updateUserProfile,
} from '#src/api/index.js'; } from '#src/api/index.js';
import { createUserByAdmin, expectRejects } from '#src/helpers/index.js'; import { createUserByAdmin, expectRejects } from '#src/helpers/index.js';
import { createNewSocialUserWithUsernameAndPassword } from '#src/helpers/interactions.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(); 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({ const user = await createUserByAdmin({
customData: { foo: 'bar' }, 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(customData).toStrictEqual({ foo: 'bar' });
expect(profile).toStrictEqual({ gender: 'neutral' });
}); });
it('should fail when create user with conflict identifiers', async () => { it('should fail when create user with conflict identifiers', async () => {
@ -95,11 +98,38 @@ describe('admin console user management', () => {
customData: { customData: {
level: 1, level: 1,
}, },
profile: {
familyName: 'new family name',
address: {
formatted: 'new formatted address',
},
},
}; };
const updatedUser = await updateUser(user.id, newUserData); const updatedUser = await updateUser(user.id, newUserData);
expect(updatedUser).toMatchObject(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 () => { 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 profile jsonb not null default '{}'::jsonb,
add column updated_at timestamptz not null default (now()); 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) => { down: async (pool) => {
await pool.query(sql` await pool.query(sql`
@ -16,6 +28,10 @@ const alteration: AlterationScript = {
drop column profile, drop column profile,
drop column updated_at; 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 */ /* 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 create function set_tenant_id() returns trigger as
$$ begin $$ begin
if new.tenant_id is not null then if new.tenant_id is not null then
@ -13,4 +14,11 @@ $$ begin
return new; return new;
end; $$ language plpgsql; 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 */ /* no_after_each */

View file

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