diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b250eeee21..10e1315dd2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -737,6 +737,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async clone(role: MiRole, moderator?: MiUser): Promise { + const suffix = ' (cloned)'; + const newName = role.name.slice(0, 256 - suffix.length) + suffix; + + return this.create({ + ...role, + name: newName, + }, moderator); + } + @bindThis public async delete(role: MiRole, moderator?: MiUser): Promise { await this.rolesRepository.delete({ id: role.id }); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index a78c3e9ae6..962055052d 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -88,6 +88,7 @@ export * as 'admin/reset-password' from './endpoints/admin/reset-password.js'; export * as 'admin/resolve-abuse-user-report' from './endpoints/admin/resolve-abuse-user-report.js'; export * as 'admin/roles/assign' from './endpoints/admin/roles/assign.js'; export * as 'admin/roles/create' from './endpoints/admin/roles/create.js'; +export * as 'admin/roles/clone' from './endpoints/admin/roles/clone.js'; export * as 'admin/roles/delete' from './endpoints/admin/roles/delete.js'; export * as 'admin/roles/list' from './endpoints/admin/roles/list.js'; export * as 'admin/roles/show' from './endpoints/admin/roles/show.js'; diff --git a/packages/backend/src/server/api/endpoints/admin/roles/clone.ts b/packages/backend/src/server/api/endpoints/admin/roles/clone.ts new file mode 100644 index 0000000000..a1e77a90ce --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/clone.ts @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { RoleService } from '@/core/RoleService.js'; +import { DI } from '@/di-symbols.js'; +import type { RolesRepository } from '@/models/_.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '@/server/api/error.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; + +export const meta = { + tags: ['admin', 'role'], + + requireCredential: true, + requireAdmin: true, + kind: 'write:admin:roles', + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'Role', + }, + + errors: { + noSuchRole: { + message: 'No such role.', + code: 'NO_SUCH_ROLE', + id: '93cc897a-b5f9-431f-b9b7-ee59035a5aed', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + roleId: { type: 'string', format: 'misskey:id' }, + }, + required: [ + 'roleId', + ], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.rolesRepository) + private rolesRepository: RolesRepository, + private roleEntityService: RoleEntityService, + private roleService: RoleService, + ) { + super(meta, paramDef, async (ps, me) => { + const role = await this.rolesRepository.findOneBy({ id: ps.roleId }); + if (role == null) { + throw new ApiError(meta.errors.noSuchRole); + } + + const cloned = await this.roleService.clone(role, me); + + return this.roleEntityService.pack(cloned, me); + }); + } +} diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 2afe22618d..edeac6f9ce 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -983,4 +983,50 @@ describe('RoleService', () => { expect(notificationService.createNotification).not.toHaveBeenCalled(); }); }); + + describe('clone', () => { + test('clones a role', async () => { + const role = await createRole({ + name: 'original role', + color: '#ff0000', + policies: { + canManageCustomEmojis: { + useDefault: false, + priority: 0, + value: true, + }, + }, + }); + + const clonedRole = await roleService.clone(role); + + expect(clonedRole).toBeDefined(); + expect(clonedRole.id).not.toBe(role.id); + expect(clonedRole.name).toBe(`${role.name} (cloned)`); + + expect(clonedRole).toEqual(expect.objectContaining({ + color: role.color, + policies: { + canManageCustomEmojis: { + useDefault: false, + priority: 0, + value: true, + }, + }, + })); + }); + + test('clones a role with a too long name', async () => { + const role = await createRole({ + name: 'a'.repeat(254), + }); + + const clonedRole = await roleService.clone(role); + + expect(clonedRole).toBeDefined(); + expect(clonedRole.id).not.toBe(role.id); + expect(clonedRole.name.endsWith(' (cloned)')).toBeTruthy(); + expect(clonedRole.name.length).toBe(256); + }); + }); }); diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue index 04e6405f9b..2cc2500efb 100644 --- a/packages/frontend/src/pages/admin/roles.role.vue +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ i18n.ts.edit }} + {{ i18n.ts.clone }} {{ i18n.ts.delete }}
@@ -97,6 +98,13 @@ function edit() { router.push('/admin/roles/' + role.id + '/edit'); } +async function clone() { + const newRole = await misskeyApi('admin/roles/clone', { + roleId: role.id, + }); + router.push('/admin/roles/' + newRole.id + '/edit'); +} + async function del() { const { canceled } = await os.confirm({ type: 'warning', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 44700add31..f9a68b1cac 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -326,6 +326,12 @@ type AdminResolveAbuseUserReportRequest = operations['admin___resolve-abuse-user // @public (undocumented) type AdminRolesAssignRequest = operations['admin___roles___assign']['requestBody']['content']['application/json']; +// @public (undocumented) +type AdminRolesCloneRequest = operations['admin___roles___clone']['requestBody']['content']['application/json']; + +// @public (undocumented) +type AdminRolesCloneResponse = operations['admin___roles___clone']['responses']['200']['content']['application/json']; + // @public (undocumented) type AdminRolesCreateRequest = operations['admin___roles___create']['requestBody']['content']['application/json']; @@ -1585,6 +1591,8 @@ declare namespace entities { AdminResetPasswordResponse, AdminResolveAbuseUserReportRequest, AdminRolesAssignRequest, + AdminRolesCloneRequest, + AdminRolesCloneResponse, AdminRolesCreateRequest, AdminRolesCreateResponse, AdminRolesDeleteRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 8827fe9c39..be7479b009 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -856,6 +856,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:roles* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index b424927316..4712c16265 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -98,6 +98,8 @@ import type { AdminResetPasswordResponse, AdminResolveAbuseUserReportRequest, AdminRolesAssignRequest, + AdminRolesCloneRequest, + AdminRolesCloneResponse, AdminRolesCreateRequest, AdminRolesCreateResponse, AdminRolesDeleteRequest, @@ -738,6 +740,7 @@ export type Endpoints = { 'admin/reset-password': { req: AdminResetPasswordRequest; res: AdminResetPasswordResponse }; 'admin/resolve-abuse-user-report': { req: AdminResolveAbuseUserReportRequest; res: EmptyResponse }; 'admin/roles/assign': { req: AdminRolesAssignRequest; res: EmptyResponse }; + 'admin/roles/clone': { req: AdminRolesCloneRequest; res: AdminRolesCloneResponse }; 'admin/roles/create': { req: AdminRolesCreateRequest; res: AdminRolesCreateResponse }; 'admin/roles/delete': { req: AdminRolesDeleteRequest; res: EmptyResponse }; 'admin/roles/list': { req: EmptyRequest; res: AdminRolesListResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 39359e3cfa..e1ba4e9704 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -101,6 +101,8 @@ export type AdminResetPasswordRequest = operations['admin___reset-password']['re export type AdminResetPasswordResponse = operations['admin___reset-password']['responses']['200']['content']['application/json']; export type AdminResolveAbuseUserReportRequest = operations['admin___resolve-abuse-user-report']['requestBody']['content']['application/json']; export type AdminRolesAssignRequest = operations['admin___roles___assign']['requestBody']['content']['application/json']; +export type AdminRolesCloneRequest = operations['admin___roles___clone']['requestBody']['content']['application/json']; +export type AdminRolesCloneResponse = operations['admin___roles___clone']['responses']['200']['content']['application/json']; export type AdminRolesCreateRequest = operations['admin___roles___create']['requestBody']['content']['application/json']; export type AdminRolesCreateResponse = operations['admin___roles___create']['responses']['200']['content']['application/json']; export type AdminRolesDeleteRequest = operations['admin___roles___delete']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 8debdec9c1..97356e0c6e 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -711,6 +711,15 @@ export type paths = { */ post: operations['admin___roles___assign']; }; + '/admin/roles/clone': { + /** + * admin/roles/clone + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:roles* + */ + post: operations['admin___roles___clone']; + }; '/admin/roles/create': { /** * admin/roles/create @@ -10385,6 +10394,60 @@ export type operations = { }; }; }; + /** + * admin/roles/clone + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:admin:roles* + */ + admin___roles___clone: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + roleId: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Role']; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * admin/roles/create * @description No description provided. diff --git a/sharkey-locales/en-US.yml b/sharkey-locales/en-US.yml index f2b613c2f5..c79302ff7a 100644 --- a/sharkey-locales/en-US.yml +++ b/sharkey-locales/en-US.yml @@ -125,6 +125,7 @@ collapseRenotes: "Collapse boosts you've already seen" collapseRenotesDescription: "Collapse boosts that you have boosted or reacted to" collapseNotesRepliedTo: "Collapse notes replied to" collapseFiles: "Collapse files" +clone: "Clone" uncollapseCW: "Uncollapse CWs on notes" expandLongNote: "Always expand long notes" autoloadConversation: "Load conversation on replies"