merge: feat: Add Role Clone Button (#1000) (!1133)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1133

Approved-by: dakkar <dakkar@thenautilus.net>
Approved-by: Hazelnoot <acomputerdog@gmail.com>
Esse commit está contido em:
Hazelnoot 2025-06-20 20:41:24 +00:00
commit a4c0ef824c
11 arquivos alterados com 219 adições e 0 exclusões

Ver arquivo

@ -737,6 +737,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
} }
} }
@bindThis
public async clone(role: MiRole, moderator?: MiUser): Promise<MiRole> {
const suffix = ' (cloned)';
const newName = role.name.slice(0, 256 - suffix.length) + suffix;
return this.create({
...role,
name: newName,
}, moderator);
}
@bindThis @bindThis
public async delete(role: MiRole, moderator?: MiUser): Promise<void> { public async delete(role: MiRole, moderator?: MiUser): Promise<void> {
await this.rolesRepository.delete({ id: role.id }); await this.rolesRepository.delete({ id: role.id });

Ver arquivo

@ -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/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/assign' from './endpoints/admin/roles/assign.js';
export * as 'admin/roles/create' from './endpoints/admin/roles/create.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/delete' from './endpoints/admin/roles/delete.js';
export * as 'admin/roles/list' from './endpoints/admin/roles/list.js'; export * as 'admin/roles/list' from './endpoints/admin/roles/list.js';
export * as 'admin/roles/show' from './endpoints/admin/roles/show.js'; export * as 'admin/roles/show' from './endpoints/admin/roles/show.js';

Ver arquivo

@ -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<typeof meta, typeof paramDef> { // 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);
});
}
}

Ver arquivo

@ -983,4 +983,50 @@ describe('RoleService', () => {
expect(notificationService.createNotification).not.toHaveBeenCalled(); 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);
});
});
}); });

Ver arquivo

@ -9,6 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_gaps"> <div class="_gaps">
<div class="_buttons"> <div class="_buttons">
<MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> <MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton>
<MkButton secondary rounded @click="clone"><i class="ti ti-copy"></i> {{ i18n.ts.clone }}</MkButton>
<MkButton danger rounded @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> <MkButton danger rounded @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton>
</div> </div>
<MkFolder> <MkFolder>
@ -97,6 +98,13 @@ function edit() {
router.push('/admin/roles/' + role.id + '/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() { async function del() {
const { canceled } = await os.confirm({ const { canceled } = await os.confirm({
type: 'warning', type: 'warning',

Ver arquivo

@ -326,6 +326,12 @@ type AdminResolveAbuseUserReportRequest = operations['admin___resolve-abuse-user
// @public (undocumented) // @public (undocumented)
type AdminRolesAssignRequest = operations['admin___roles___assign']['requestBody']['content']['application/json']; 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) // @public (undocumented)
type AdminRolesCreateRequest = operations['admin___roles___create']['requestBody']['content']['application/json']; type AdminRolesCreateRequest = operations['admin___roles___create']['requestBody']['content']['application/json'];
@ -1585,6 +1591,8 @@ declare namespace entities {
AdminResetPasswordResponse, AdminResetPasswordResponse,
AdminResolveAbuseUserReportRequest, AdminResolveAbuseUserReportRequest,
AdminRolesAssignRequest, AdminRolesAssignRequest,
AdminRolesCloneRequest,
AdminRolesCloneResponse,
AdminRolesCreateRequest, AdminRolesCreateRequest,
AdminRolesCreateResponse, AdminRolesCreateResponse,
AdminRolesDeleteRequest, AdminRolesDeleteRequest,

Ver arquivo

@ -856,6 +856,17 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:roles*
*/
request<E extends 'admin/roles/clone', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/** /**
* No description provided. * No description provided.
* *

Ver arquivo

@ -98,6 +98,8 @@ import type {
AdminResetPasswordResponse, AdminResetPasswordResponse,
AdminResolveAbuseUserReportRequest, AdminResolveAbuseUserReportRequest,
AdminRolesAssignRequest, AdminRolesAssignRequest,
AdminRolesCloneRequest,
AdminRolesCloneResponse,
AdminRolesCreateRequest, AdminRolesCreateRequest,
AdminRolesCreateResponse, AdminRolesCreateResponse,
AdminRolesDeleteRequest, AdminRolesDeleteRequest,
@ -738,6 +740,7 @@ export type Endpoints = {
'admin/reset-password': { req: AdminResetPasswordRequest; res: AdminResetPasswordResponse }; 'admin/reset-password': { req: AdminResetPasswordRequest; res: AdminResetPasswordResponse };
'admin/resolve-abuse-user-report': { req: AdminResolveAbuseUserReportRequest; res: EmptyResponse }; 'admin/resolve-abuse-user-report': { req: AdminResolveAbuseUserReportRequest; res: EmptyResponse };
'admin/roles/assign': { req: AdminRolesAssignRequest; 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/create': { req: AdminRolesCreateRequest; res: AdminRolesCreateResponse };
'admin/roles/delete': { req: AdminRolesDeleteRequest; res: EmptyResponse }; 'admin/roles/delete': { req: AdminRolesDeleteRequest; res: EmptyResponse };
'admin/roles/list': { req: EmptyRequest; res: AdminRolesListResponse }; 'admin/roles/list': { req: EmptyRequest; res: AdminRolesListResponse };

Ver arquivo

@ -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 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 AdminResolveAbuseUserReportRequest = operations['admin___resolve-abuse-user-report']['requestBody']['content']['application/json'];
export type AdminRolesAssignRequest = operations['admin___roles___assign']['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 AdminRolesCreateRequest = operations['admin___roles___create']['requestBody']['content']['application/json'];
export type AdminRolesCreateResponse = operations['admin___roles___create']['responses']['200']['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']; export type AdminRolesDeleteRequest = operations['admin___roles___delete']['requestBody']['content']['application/json'];

Ver arquivo

@ -711,6 +711,15 @@ export type paths = {
*/ */
post: operations['admin___roles___assign']; 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': {
/** /**
* 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 * admin/roles/create
* @description No description provided. * @description No description provided.

Ver arquivo

@ -125,6 +125,7 @@ collapseRenotes: "Collapse boosts you've already seen"
collapseRenotesDescription: "Collapse boosts that you have boosted or reacted to" collapseRenotesDescription: "Collapse boosts that you have boosted or reacted to"
collapseNotesRepliedTo: "Collapse notes replied to" collapseNotesRepliedTo: "Collapse notes replied to"
collapseFiles: "Collapse files" collapseFiles: "Collapse files"
clone: "Clone"
uncollapseCW: "Uncollapse CWs on notes" uncollapseCW: "Uncollapse CWs on notes"
expandLongNote: "Always expand long notes" expandLongNote: "Always expand long notes"
autoloadConversation: "Load conversation on replies" autoloadConversation: "Load conversation on replies"