From ebf21b474a6cecf71eab508f62115f0942f9418c Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 20:27:20 +0200 Subject: [PATCH 1/9] feat(backend): Add Clone Endpoint --- packages/backend/src/core/RoleService.ts | 14 +++++ .../backend/src/server/api/endpoint-list.ts | 1 + .../server/api/endpoints/admin/roles/clone.ts | 60 +++++++++++++++++++ packages/backend/test/unit/RoleService.ts | 46 ++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 packages/backend/src/server/api/endpoints/admin/roles/clone.ts diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index b250eeee21..f15c5ab45f 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -737,6 +737,20 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { } } + @bindThis + public async clone(role: MiRole, moderator?: MiUser): Promise { + let newName = `${role.name} (cloned)`; + + if (newName.length > 256) { + newName = newName.slice(0, 256); + } + + 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..83a7a54c3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/roles/clone.ts @@ -0,0 +1,60 @@ +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..534556b056 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 an 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 roll 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).toBe(`${role.name} (`); + expect(clonedRole.name.length).toBe(256); + }); + }); }); From 6d98c9068c4489f55fd300bb63f6bbbe9cd5e950 Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 20:27:40 +0200 Subject: [PATCH 2/9] feat(frontend): Add Clone Button To Role Page --- locales/en-US.yml | 1 + .../frontend/src/pages/admin/roles.role.vue | 8 +++ packages/misskey-js/etc/misskey-js.api.md | 8 +++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 11 ++++ packages/misskey-js/src/autogen/endpoint.ts | 3 + packages/misskey-js/src/autogen/entities.ts | 2 + packages/misskey-js/src/autogen/types.ts | 63 +++++++++++++++++++ 7 files changed, 96 insertions(+) diff --git a/locales/en-US.yml b/locales/en-US.yml index 230717f372..3f0b88c613 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -51,6 +51,7 @@ copyContent: "Copy contents" copyLink: "Copy link" copyRemoteLink: "Copy remote link" copyLinkRenote: "Copy renote link" +clone: "Clone" delete: "Delete" deleteAndEdit: "Delete and edit" deleteAndEditConfirm: "Are you sure you want to redraft this note? This means you will lose all reactions, renotes, and replies to it." 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. From 0deaf9157e77568151867b852cfda6db830e908e Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 21:26:42 +0200 Subject: [PATCH 3/9] refactor: Move Translation To SharkeyLocales --- locales/en-US.yml | 1 - sharkey-locales/en-US.yml | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/locales/en-US.yml b/locales/en-US.yml index 3f0b88c613..230717f372 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -51,7 +51,6 @@ copyContent: "Copy contents" copyLink: "Copy link" copyRemoteLink: "Copy remote link" copyLinkRenote: "Copy renote link" -clone: "Clone" delete: "Delete" deleteAndEdit: "Delete and edit" deleteAndEditConfirm: "Are you sure you want to redraft this note? This means you will lose all reactions, renotes, and replies to it." 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" From 18a979ea4effb80aa626b774f7d7f9670de26525 Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 21:32:05 +0200 Subject: [PATCH 4/9] refactor: Add Licence Header --- .../backend/src/server/api/endpoints/admin/roles/clone.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/backend/src/server/api/endpoints/admin/roles/clone.ts b/packages/backend/src/server/api/endpoints/admin/roles/clone.ts index 83a7a54c3a..a1e77a90ce 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/clone.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/clone.ts @@ -1,3 +1,8 @@ +/* + * 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'; From 98a63e8e96aee9a324a172371290c76ff957efeb Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 21:33:17 +0200 Subject: [PATCH 5/9] feat: Truncate Old Name --- packages/backend/src/core/RoleService.ts | 9 ++++++--- packages/backend/test/unit/RoleService.ts | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index f15c5ab45f..4a65cd9342 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -739,12 +739,15 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async clone(role: MiRole, moderator?: MiUser): Promise { - let newName = `${role.name} (cloned)`; + const suffix = ' (cloned)'; + let newName = role.name; - if (newName.length > 256) { - newName = newName.slice(0, 256); + if (newName.length > 256 - suffix.length) { + newName = newName.slice(0, 256 - suffix.length); } + newName += suffix; + return this.create({ ...role, name: newName, diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 534556b056..5a643a6a2c 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -1025,7 +1025,7 @@ describe('RoleService', () => { expect(clonedRole).toBeDefined(); expect(clonedRole.id).not.toBe(role.id); - expect(clonedRole.name).toBe(`${role.name} (`); + expect(clonedRole.name.endsWith(' (cloned)')).toBeTruthy(); expect(clonedRole.name.length).toBe(256); }); }); From 0e52763bedb527ab0fc2a7ebf28601e8cda45b43 Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 21:38:23 +0200 Subject: [PATCH 6/9] refactor: Fix Typo --- packages/backend/test/unit/RoleService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 5a643a6a2c..206b1714e5 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -1016,7 +1016,7 @@ describe('RoleService', () => { })); }); - test('clones a roll with a too long name', async () => { + test('clones a role with a too long name', async () => { const role = await createRole({ name: 'a'.repeat(254), }); From 3dadbdb8546b3f87224712272fa3240e584fbeba Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 21:41:48 +0200 Subject: [PATCH 7/9] refactor: Simplify Statement --- packages/backend/src/core/RoleService.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 4a65cd9342..637908f93b 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -742,9 +742,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { const suffix = ' (cloned)'; let newName = role.name; - if (newName.length > 256 - suffix.length) { - newName = newName.slice(0, 256 - suffix.length); - } + newName = newName.slice(0, 256 - suffix.length); newName += suffix; From 3ce891467382b60f5e21adbeac3319fca41168e1 Mon Sep 17 00:00:00 2001 From: Lilly Schramm Date: Fri, 20 Jun 2025 21:55:02 +0200 Subject: [PATCH 8/9] refactor: Simplify Statement (again) --- packages/backend/src/core/RoleService.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts index 637908f93b..10e1315dd2 100644 --- a/packages/backend/src/core/RoleService.ts +++ b/packages/backend/src/core/RoleService.ts @@ -740,11 +740,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit { @bindThis public async clone(role: MiRole, moderator?: MiUser): Promise { const suffix = ' (cloned)'; - let newName = role.name; - - newName = newName.slice(0, 256 - suffix.length); - - newName += suffix; + const newName = role.name.slice(0, 256 - suffix.length) + suffix; return this.create({ ...role, From 87f6d84229beda910749509ead11d151f9601a33 Mon Sep 17 00:00:00 2001 From: Lillychan Date: Fri, 20 Jun 2025 20:10:04 +0000 Subject: [PATCH 9/9] Apply 1 suggestion(s) to 1 file(s) --- packages/backend/test/unit/RoleService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/test/unit/RoleService.ts b/packages/backend/test/unit/RoleService.ts index 206b1714e5..edeac6f9ce 100644 --- a/packages/backend/test/unit/RoleService.ts +++ b/packages/backend/test/unit/RoleService.ts @@ -985,7 +985,7 @@ describe('RoleService', () => { }); describe('clone', () => { - test('clones an role', async () => { + test('clones a role', async () => { const role = await createRole({ name: 'original role', color: '#ff0000',