diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 904737884..c3eb0bb52 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -762,6 +762,9 @@ common/views/components/user-group-editor.vue: users: "メンバー" rename: "グループ名を変更" delete: "グループを削除" + transfer: "グループを譲渡" + transfer-are-you-sure: "グループ「$1」を「@$2」さんに譲渡しますか?" + transferred: "グループを譲渡しました" remove-user: "このグループから削除" delete-are-you-sure: "グループ「$1」を削除しますか?" deleted: "削除しました" diff --git a/src/client/app/common/views/pages/user-group-editor.vue b/src/client/app/common/views/pages/user-group-editor.vue index ef79689ae..a32148cd7 100644 --- a/src/client/app/common/views/pages/user-group-editor.vue +++ b/src/client/app/common/views/pages/user-group-editor.vue @@ -7,6 +7,7 @@ <ui-margin> <ui-button @click="rename"><fa :icon="faICursor"/> {{ $t('rename') }}</ui-button> <ui-button @click="del"><fa :icon="faTrashAlt"/> {{ $t('delete') }}</ui-button> + <ui-button @click="transfer"><fa :icon="faCrown"/> {{ $t('transfer') }}</ui-button> </ui-margin> </section> </ui-container> @@ -28,9 +29,10 @@ <div> <header> <b><mk-user-name :user="user"/></b> + <span class="is-owner" v-if="group.owner === user.id">owner</span> <span class="username">@{{ user | acct }}</span> </header> - <div> + <div v-if="group.owner !== user.id"> <a @click="remove(user)">{{ $t('remove-user') }}</a> </div> </div> @@ -44,7 +46,7 @@ <script lang="ts"> import Vue from 'vue'; import i18n from '../../../i18n'; -import { faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { faCrown, faICursor, faUsers, faPlus } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ @@ -60,7 +62,7 @@ export default Vue.extend({ return { group: null, users: [], - faICursor, faTrashAlt, faUsers, faPlus + faCrown, faICursor, faTrashAlt, faUsers, faPlus }; }, @@ -78,6 +80,14 @@ export default Vue.extend({ }, methods: { + fetchGroup() { + this.$root.api('users/groups/show', { + groupId: this.group.id + }).then(group => { + this.group = group; + }) + }, + fetchUsers() { this.$root.api('users/show', { userIds: this.group.userIds @@ -97,8 +107,15 @@ export default Vue.extend({ this.$root.api('users/groups/update', { groupId: this.group.id, name: name + }).then(() => { + this.fetchGroup(); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); }); - }); + }) }, del() { @@ -130,12 +147,17 @@ export default Vue.extend({ groupId: this.group.id, userId: user.id }).then(() => { + this.fetchGroup(); this.fetchUsers(); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); }); }, async invite() { - const t = this.$t('invited'); const { result: user } = await this.$root.dialog({ user: { local: true @@ -148,7 +170,44 @@ export default Vue.extend({ }).then(() => { this.$root.dialog({ type: 'success', - text: t + text: this.$t('invited') + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); + }); + }, + + async transfer() { + const { result: user } = await this.$root.dialog({ + user: { + local: true + } + }); + if (user == null) return; + + this.$root.dialog({ + type: 'warning', + text: this.$t('transfer-are-you-sure').replace('$1', this.group.name).replace('$2', user.username), + showCancelButton: true + }).then(({ canceled }) => { + if (canceled) return; + + this.$root.api('users/groups/transfer', { + groupId: this.group.id, + userId: user.id + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('transferred') + }); + }).catch(e => { + this.$root.dialog({ + type: 'error', + text: e + }); }); }); } @@ -179,6 +238,16 @@ export default Vue.extend({ > header color var(--text) + > .is-owner + flex-shrink 0 + align-self center + margin-left 8px + padding 1px 6px + font-size 80% + background var(--groupUserListOwnerBg) + color var(--groupUserListOwnerFg) + border-radius 3px + > .username margin-left 8px opacity 0.7 diff --git a/src/client/app/init.ts b/src/client/app/init.ts index da7baff4f..52da380e8 100644 --- a/src/client/app/init.ts +++ b/src/client/app/init.ts @@ -78,6 +78,7 @@ import { faKey, faBan, faCogs, + faCrown, faUnlockAlt, faPuzzlePiece, faMobileAlt, @@ -210,6 +211,7 @@ library.add( faKey, faBan, faCogs, + faCrown, faUnlockAlt, faPuzzlePiece, faMobileAlt, diff --git a/src/client/themes/dark.json5 b/src/client/themes/dark.json5 index 8e0c726b4..0665d5990 100644 --- a/src/client/themes/dark.json5 +++ b/src/client/themes/dark.json5 @@ -235,5 +235,8 @@ pageBlockBorder: 'rgba(255, 255, 255, 0.1)', pageBlockBorderHover: 'rgba(255, 255, 255, 0.15)', + + groupUserListOwnerFg: '#f15f71', + groupUserListOwnerBg: '#5d282e' }, } diff --git a/src/client/themes/light.json5 b/src/client/themes/light.json5 index 1fff18176..cbe456ca5 100644 --- a/src/client/themes/light.json5 +++ b/src/client/themes/light.json5 @@ -235,5 +235,8 @@ pageBlockBorder: 'rgba(0, 0, 0, 0.1)', pageBlockBorderHover: 'rgba(0, 0, 0, 0.15)', + + groupUserListOwnerFg: '#f15f71', + groupUserListOwnerBg: '#ffdfdf' }, } diff --git a/src/models/repositories/user-group.ts b/src/models/repositories/user-group.ts index 8bb1ae833..dbbe8bf84 100644 --- a/src/models/repositories/user-group.ts +++ b/src/models/repositories/user-group.ts @@ -21,6 +21,7 @@ export class UserGroupRepository extends Repository<UserGroup> { id: userGroup.id, createdAt: userGroup.createdAt.toISOString(), name: userGroup.name, + owner: userGroup.userId, userIds: users.map(x => x.userId) }; } @@ -48,6 +49,11 @@ export const packedUserGroupSchema = { optional: bool.false, nullable: bool.false, description: 'The name of the UserGroup.' }, + owner: { + type: types.string, + nullable: bool.false, optional: bool.false, + format: 'id', + }, userIds: { type: types.array, nullable: bool.false, optional: bool.true, diff --git a/src/server/api/endpoints/users/groups/transfer.ts b/src/server/api/endpoints/users/groups/transfer.ts new file mode 100644 index 000000000..3baa182ab --- /dev/null +++ b/src/server/api/endpoints/users/groups/transfer.ts @@ -0,0 +1,86 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { getUser } from '../../../common/getters'; +import { UserGroups, UserGroupJoinings } from '../../../../../models'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーグループを指定したユーザーグループ内のユーザーに譲渡します。', + 'en-US': 'Transfer user group ownership to another user in group.' + }, + + tags: ['groups', 'users'], + + requireCredential: true, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + }, + + userId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象のユーザーのID', + 'en-US': 'Target user ID' + } + }, + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '8e31d36b-2f88-4ccd-a438-e2d78a9162db' + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '711f7ebb-bbb9-4dfa-b540-b27809fed5e9' + }, + + noSuchGroupMember: { + message: 'No such group member.', + code: 'NO_SUCH_GROUP_MEMBER', + id: 'd31bebee-196d-42c2-9a3e-9474d4be6cc4' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch(e => { + if (e.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOne({ + userGroupId: userGroup.id, + userId: user.id + }); + + if (!joining) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await UserGroups.update(userGroup.id, { + userId: ps.userId + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/src/server/api/endpoints/users/groups/update.ts b/src/server/api/endpoints/users/groups/update.ts new file mode 100644 index 000000000..ad9a1faa2 --- /dev/null +++ b/src/server/api/endpoints/users/groups/update.ts @@ -0,0 +1,62 @@ +import $ from 'cafy'; +import { ID } from '../../../../../misc/cafy-id'; +import define from '../../../define'; +import { ApiError } from '../../../error'; +import { UserGroups } from '../../../../../models'; + +export const meta = { + desc: { + 'ja-JP': '指定したユーザーグループを更新します。', + 'en-US': 'Update a user group' + }, + + tags: ['groups'], + + requireCredential: true, + + kind: 'write:user-groups', + + params: { + groupId: { + validator: $.type(ID), + desc: { + 'ja-JP': '対象となるユーザーグループのID', + 'en-US': 'ID of target user group' + } + }, + + name: { + validator: $.str.range(1, 100), + desc: { + 'ja-JP': 'このユーザーグループの名前', + 'en-US': 'name of this user group' + } + } + }, + + errors: { + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: '9081cda3-7a9e-4fac-a6ce-908d70f282f6' + }, + } +}; + +export default define(meta, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOne({ + id: ps.groupId, + userId: me.id + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.update(userGroup.id, { + name: ps.name + }); + + return await UserGroups.pack(userGroup.id); +});