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:
parent
1bc40faf98
commit
6feb531435
8 changed files with 115 additions and 3 deletions
|
@ -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]]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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();
|
||||||
|
`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 */
|
||||||
|
|
|
@ -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();
|
||||||
|
|
Loading…
Reference in a new issue