diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2023-01-12 21:02:26 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-01-12 21:02:26 +0900 |
| commit | 2470afaa2e200fb2fc748e0f8eef5e2c215369b6 (patch) | |
| tree | c270452679996127a9d15c4ba5f97b39bb9ba560 /packages/frontend/src | |
| parent | Update CHANGELOG.md (diff) | |
| download | sharkey-2470afaa2e200fb2fc748e0f8eef5e2c215369b6.tar.gz sharkey-2470afaa2e200fb2fc748e0f8eef5e2c215369b6.tar.bz2 sharkey-2470afaa2e200fb2fc748e0f8eef5e2c215369b6.zip | |
Role (#9437)
* wip
* Update CHANGELOG.md
* wip
* wip
* wip
* Update create.ts
* wip
* wip
* Update CHANGELOG.md
* wip
* wip
* wip
* wip
* wip
* wip
* wip
* Update CHANGELOG.md
* wip
* wip
* Update delete.ts
* Update delete.ts
* wip
* wip
* wip
* Update account-info.vue
* wip
* wip
* Update settings.vue
* Update user-info.vue
* wip
* Update show-file.ts
* Update show-user.ts
* wip
* wip
* Update delete.ts
* wip
* wip
* Update overview.moderators.vue
* Create 1673500412259-Role.js
* wip
* wip
* Update roles.vue
* 色
* Update roles.vue
* integrate silence
* wip
* wip
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/components/MkRolePreview.vue | 32 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkUserCardMini.vue | 2 | ||||
| -rw-r--r-- | packages/frontend/src/directives/adaptive-bg.ts | 24 | ||||
| -rw-r--r-- | packages/frontend/src/directives/index.ts | 2 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/index.vue | 5 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.edit.vue | 65 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.editor.vue | 193 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.role.vue | 121 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/roles.vue | 115 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/settings.vue | 35 | ||||
| -rw-r--r-- | packages/frontend/src/pages/admin/users.vue | 1 | ||||
| -rw-r--r-- | packages/frontend/src/pages/timeline.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user-info.vue | 121 | ||||
| -rw-r--r-- | packages/frontend/src/pages/user/home.vue | 4 | ||||
| -rw-r--r-- | packages/frontend/src/router.ts | 17 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/get-user-menu.ts | 32 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/tl-column.vue | 4 |
17 files changed, 654 insertions, 123 deletions
diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue new file mode 100644 index 0000000000..ddd7dbb250 --- /dev/null +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -0,0 +1,32 @@ +<template> +<MkA v-adaptive-bg :to="`/admin/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> + <div :class="$style.title">{{ role.name }}</div> + <div :class="$style.description">{{ role.description }}</div> +</MkA> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import * as misskey from 'misskey-js'; +import * as os from '@/os'; + +const props = defineProps<{ + role: any; +}>(); +</script> + +<style lang="scss" module> +.root { + display: block; + padding: 16px 20px; + border-left: solid 6px var(--color); +} + +.title { + font-weight: bold; +} + +.description { + opacity: 0.7; +} +</style> diff --git a/packages/frontend/src/components/MkUserCardMini.vue b/packages/frontend/src/components/MkUserCardMini.vue index 1a4c494987..be8a4c408e 100644 --- a/packages/frontend/src/components/MkUserCardMini.vue +++ b/packages/frontend/src/components/MkUserCardMini.vue @@ -1,5 +1,5 @@ <template> -<div :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]"> +<div v-adaptive-bg :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]"> <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> <div class="body"> <span class="name"><MkUserName class="name" :user="user"/></span> diff --git a/packages/frontend/src/directives/adaptive-bg.ts b/packages/frontend/src/directives/adaptive-bg.ts new file mode 100644 index 0000000000..313aad7996 --- /dev/null +++ b/packages/frontend/src/directives/adaptive-bg.ts @@ -0,0 +1,24 @@ +import { Directive } from 'vue'; + +export default { + mounted(src, binding, vn) { + const getBgColor = (el: HTMLElement) => { + const style = window.getComputedStyle(el); + if (style.backgroundColor && !['rgba(0, 0, 0, 0)', 'rgba(0,0,0,0)', 'transparent'].includes(style.backgroundColor)) { + return style.backgroundColor; + } else { + return el.parentElement ? getBgColor(el.parentElement) : 'transparent'; + } + }; + + const parentBg = getBgColor(src.parentElement); + + const myBg = window.getComputedStyle(src).backgroundColor; + + if (parentBg === myBg) { + src.style.backgroundColor = 'var(--bg)'; + } else { + src.style.backgroundColor = myBg; + } + }, +} as Directive; diff --git a/packages/frontend/src/directives/index.ts b/packages/frontend/src/directives/index.ts index 93d1b4f43d..a690fd6c42 100644 --- a/packages/frontend/src/directives/index.ts +++ b/packages/frontend/src/directives/index.ts @@ -10,6 +10,7 @@ import anim from './anim'; import clickAnime from './click-anime'; import panel from './panel'; import adaptiveBorder from './adaptive-border'; +import adaptiveBg from './adaptive-bg'; export default function(app: App) { app.directive('userPreview', userPreview); @@ -23,4 +24,5 @@ export default function(app: App) { app.directive('click-anime', clickAnime); app.directive('panel', panel); app.directive('adaptive-border', adaptiveBorder); + app.directive('adaptive-bg', adaptiveBg); } diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index c90a1c1b00..1d0d87e422 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -131,6 +131,11 @@ const menuDef = $computed(() => [{ text: i18n.ts.abuseReports, to: '/admin/abuses', active: currentPage?.route.name === 'abuses', + }, { + icon: 'ti ti-badges', + text: i18n.ts.roles, + to: '/admin/roles', + active: currentPage?.route.name === 'roles', }], }, { title: i18n.ts.settings, diff --git a/packages/frontend/src/pages/admin/roles.edit.vue b/packages/frontend/src/pages/admin/roles.edit.vue new file mode 100644 index 0000000000..3cb4e2deb9 --- /dev/null +++ b/packages/frontend/src/pages/admin/roles.edit.vue @@ -0,0 +1,65 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600"> + <XEditor :role="role" @created="created" @updated="updated"/> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XHeader from './_header_.vue'; +import XEditor from './roles.editor.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSlot from '@/components/form/slot.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +const props = defineProps<{ + id?: string; +}>(); + +let role = $ref(null); + +if (props.id) { + role = await os.api('admin/roles/show', { + roleId: props.id, + }); +} + +function created(r) { + router.push('/admin/roles/' + r.id); +} + +function updated() { + router.push('/admin/roles/' + role.id); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => role ? { + title: i18n.ts._role.edit + ': ' + role.name, + icon: 'ti ti-badge', +} : { + title: i18n.ts._role.new, + icon: 'ti ti-badge', +})); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue new file mode 100644 index 0000000000..b6f0cd9f57 --- /dev/null +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -0,0 +1,193 @@ +<template> +<div class="_gaps"> + <MkInput v-model="name" :readonly="readonly"> + <template #label>{{ i18n.ts._role.name }}</template> + </MkInput> + + <MkTextarea v-model="description" :readonly="readonly"> + <template #label>{{ i18n.ts._role.description }}</template> + </MkTextarea> + + <MkInput v-model="color"> + <template #label>{{ i18n.ts.color }}</template> + <template #caption>#RRGGBB</template> + </MkInput> + + <MkSelect v-model="roleType" :readonly="readonly"> + <template #label>{{ i18n.ts._role.type }}</template> + <template #caption><div v-html="i18n.ts._role.descriptionOfType.replaceAll('\n', '<br>')"></div></template> + <option value="normal">{{ i18n.ts.noramlUser }}</option> + <option value="moderator">{{ i18n.ts.moderator }}</option> + <option value="administrator">{{ i18n.ts.administrator }}</option> + </MkSelect> + + <FormSlot> + <template #label>{{ i18n.ts._role.options }}</template> + <div class="_gaps_s"> + <MkFolder> + <template #label>{{ i18n.ts._role._options.gtlAvailable }}</template> + <template #suffix>{{ options_gtlAvailable_useDefault ? i18n.ts._role.useBaseValue : (options_gtlAvailable_value ? i18n.ts.yes : i18n.ts.no) }}</template> + <div class="_gaps"> + <MkSwitch v-model="options_gtlAvailable_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="options_gtlAvailable_value" :disabled="options_gtlAvailable_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.ltlAvailable }}</template> + <template #suffix>{{ options_ltlAvailable_useDefault ? i18n.ts._role.useBaseValue : (options_ltlAvailable_value ? i18n.ts.yes : i18n.ts.no) }}</template> + <div class="_gaps"> + <MkSwitch v-model="options_ltlAvailable_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="options_ltlAvailable_value" :disabled="options_ltlAvailable_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.canPublicNote }}</template> + <template #suffix>{{ options_canPublicNote_useDefault ? i18n.ts._role.useBaseValue : (options_canPublicNote_value ? i18n.ts.yes : i18n.ts.no) }}</template> + <div class="_gaps"> + <MkSwitch v-model="options_canPublicNote_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="options_canPublicNote_value" :disabled="options_canPublicNote_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </div> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> + <template #suffix>{{ options_driveCapacityMb_useDefault ? i18n.ts._role.useBaseValue : (options_driveCapacityMb_value + 'MB') }}</template> + <div class="_gaps"> + <MkSwitch v-model="options_driveCapacityMb_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="options_driveCapacityMb_value" :disabled="options_driveCapacityMb_useDefault" type="number" :readonly="readonly"> + <template #suffix>MB</template> + </MkInput> + </div> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.antennaMax }}</template> + <template #suffix>{{ options_antennaLimit_useDefault ? i18n.ts._role.useBaseValue : (options_antennaLimit_value) }}</template> + <div class="_gaps"> + <MkSwitch v-model="options_antennaLimit_useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkInput v-model="options_antennaLimit_value" :disabled="options_antennaLimit_useDefault" type="number" :readonly="readonly"> + </MkInput> + </div> + </MkFolder> + </div> + </FormSlot> + + <MkSwitch v-model="canEditMembersByModerator" :readonly="readonly"> + <template #label>{{ i18n.ts._role.canEditMembersByModerator }}</template> + <template #caption>{{ i18n.ts._role.descriptionOfCanEditMembersByModerator }}</template> + </MkSwitch> + + <MkSwitch v-model="isPublic" :readonly="readonly"> + <template #label>{{ i18n.ts._role.isPublic }}</template> + <template #caption>{{ i18n.ts._role.descriptionOfIsPublic }}</template> + </MkSwitch> + + <div v-if="!readonly" class="_buttons"> + <MkButton primary rounded @click="save"><i class="ti ti-check"></i> {{ role ? i18n.ts.save : i18n.ts.create }}</MkButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkTextarea from '@/components/MkTextarea.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkButton from '@/components/MkButton.vue'; +import FormSlot from '@/components/form/slot.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const emit = defineEmits<{ + (ev: 'created', payload: any): void; + (ev: 'updated'): void; +}>(); + +const props = defineProps<{ + role?: any; + readonly?: boolean; +}>(); + +const role = props.role; + +let name = $ref(role?.name ?? 'New Role'); +let description = $ref(role?.description ?? ''); +let roleType = $ref(role?.isAdministrator ? 'administrator' : role?.isModerator ? 'moderator' : 'normal'); +let color = $ref(role?.color ?? null); +let isPublic = $ref(role?.isPublic ?? false); +let canEditMembersByModerator = $ref(role?.canEditMembersByModerator ?? false); +let options_gtlAvailable_useDefault = $ref(role?.options?.gtlAvailable?.useDefault ?? true); +let options_gtlAvailable_value = $ref(role?.options?.gtlAvailable?.value ?? false); +let options_ltlAvailable_useDefault = $ref(role?.options?.ltlAvailable?.useDefault ?? true); +let options_ltlAvailable_value = $ref(role?.options?.ltlAvailable?.value ?? false); +let options_canPublicNote_useDefault = $ref(role?.options?.canPublicNote?.useDefault ?? true); +let options_canPublicNote_value = $ref(role?.options?.canPublicNote?.value ?? false); +let options_driveCapacityMb_useDefault = $ref(role?.options?.driveCapacityMb?.useDefault ?? true); +let options_driveCapacityMb_value = $ref(role?.options?.driveCapacityMb?.value ?? 0); +let options_antennaLimit_useDefault = $ref(role?.options?.antennaLimit?.useDefault ?? true); +let options_antennaLimit_value = $ref(role?.options?.antennaLimit?.value ?? 0); + +function getOptions() { + return { + gtlAvailable: { useDefault: options_gtlAvailable_useDefault, value: options_gtlAvailable_value }, + ltlAvailable: { useDefault: options_ltlAvailable_useDefault, value: options_ltlAvailable_value }, + canPublicNote: { useDefault: options_canPublicNote_useDefault, value: options_canPublicNote_value }, + driveCapacityMb: { useDefault: options_driveCapacityMb_useDefault, value: options_driveCapacityMb_value }, + antennaLimit: { useDefault: options_antennaLimit_useDefault, value: options_antennaLimit_value }, + }; +} + +async function save() { + if (props.readonly) return; + if (role) { + os.apiWithDialog('admin/roles/update', { + roleId: role.id, + name, + description, + color: color === '' ? null : color, + isAdministrator: roleType === 'administrator', + isModerator: roleType === 'moderator', + isPublic, + canEditMembersByModerator, + options: getOptions(), + }); + emit('updated'); + } else { + const created = await os.apiWithDialog('admin/roles/create', { + name, + description, + color: color === '' ? null : color, + isAdministrator: roleType === 'administrator', + isModerator: roleType === 'moderator', + isPublic, + canEditMembersByModerator, + options: getOptions(), + }); + emit('created', created); + } +} +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/roles.role.vue b/packages/frontend/src/pages/admin/roles.role.vue new file mode 100644 index 0000000000..8c18b02632 --- /dev/null +++ b/packages/frontend/src/pages/admin/roles.role.vue @@ -0,0 +1,121 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_gaps"> + <div class="_buttons"> + <MkButton primary rounded @click="edit"><i class="ti ti-pencil"></i> {{ i18n.ts.edit }}</MkButton> + <MkButton danger rounded @click="del"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> + <MkFolder> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.info }}</template> + <XEditor :role="role" readonly/> + </MkFolder> + <MkFolder default-open> + <template #icon><i class="ti ti-users"></i></template> + <template #label>{{ i18n.ts.users }}</template> + <template #suffix>{{ role.users.length }}</template> + <div class="_gaps"> + <MkButton primary rounded @click="assign"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> + + <div v-for="user in role.users" :key="user.id" :class="$style.userItem"> + <MkA :class="$style.user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + <button class="_button" :class="$style.unassign" @click="unassign(user, $event)"><i class="ti ti-x"></i></button> + </div> + </div> + </MkFolder> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive } from 'vue'; +import XHeader from './_header_.vue'; +import XEditor from './roles.editor.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { useRouter } from '@/router'; +import MkButton from '@/components/MkButton.vue'; +import MkUserCardMini from '@/components/MkUserCardMini.vue'; + +const router = useRouter(); + +const props = defineProps<{ + id?: string; +}>(); + +const role = reactive(await os.api('admin/roles/show', { + roleId: props.id, +})); + +function edit() { + router.push('/admin/roles/' + role.id + '/edit'); +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: role.name }), + }); + if (canceled) return; + + await os.apiWithDialog('admin/roles/delete', { + roleId: role.id, + }); + + router.push('/admin/roles'); +} + +function assign() { + os.selectUser().then(async (user) => { + await os.apiWithDialog('admin/roles/assign', { roleId: role.id, userId: user.id }); + role.users.push(user); + }); +} + +async function unassign(user, ev) { + os.popupMenu([{ + text: i18n.ts.unassign, + icon: 'ti ti-x', + danger: true, + action: async () => { + await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.id }); + role.users = role.users.filter(u => u.id !== user.id); + }, + }], ev.currentTarget ?? ev.target); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.role + ': ' + role.name, + icon: 'ti ti-badge', +}))); +</script> + +<style lang="scss" module> +.userItem { + display: flex; +} + +.user { + flex: 1; +} + +.unassign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} +</style> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue new file mode 100644 index 0000000000..f74a3dcf5a --- /dev/null +++ b/packages/frontend/src/pages/admin/roles.vue @@ -0,0 +1,115 @@ +<template> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_gaps"> + <MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton> + <MkFolder> + <template #label>{{ i18n.ts._role.baseRole }}</template> + <div class="_gaps"> + <MkFolder> + <template #label>{{ i18n.ts._role._options.gtlAvailable }}</template> + <template #suffix>{{ options_gtlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="options_gtlAvailable"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.ltlAvailable }}</template> + <template #suffix>{{ options_ltlAvailable ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="options_ltlAvailable"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.canPublicNote }}</template> + <template #suffix>{{ options_canPublicNote ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="options_canPublicNote"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.driveCapacity }}</template> + <template #suffix>{{ options_driveCapacityMb }}MB</template> + <MkInput v-model="options_driveCapacityMb" type="number"> + <template #suffix>MB</template> + </MkInput> + </MkFolder> + + <MkFolder> + <template #label>{{ i18n.ts._role._options.antennaMax }}</template> + <template #suffix>{{ options_antennaLimit }}</template> + <MkInput v-model="options_antennaLimit" type="number"> + </MkInput> + </MkFolder> + <MkButton primary rounded @click="updateBaseRole">{{ i18n.ts.save }}</MkButton> + </div> + </MkFolder> + <div class="_gaps_s"> + <MkRolePreview v-for="role in roles" :key="role.id" :role="role"/> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import XHeader from './_header_.vue'; +import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; +import MkPagination from '@/components/MkPagination.vue'; +import MkFolder from '@/components/MkFolder.vue'; +import MkSwitch from '@/components/MkSwitch.vue'; +import MkButton from '@/components/MkButton.vue'; +import MkRolePreview from '@/components/MkRolePreview.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { instance } from '@/instance'; +import { useRouter } from '@/router'; + +const router = useRouter(); + +const roles = await os.api('admin/roles/list'); + +let options_gtlAvailable = $ref(instance.baseRole.gtlAvailable); +let options_ltlAvailable = $ref(instance.baseRole.ltlAvailable); +let options_canPublicNote = $ref(instance.baseRole.canPublicNote); +let options_driveCapacityMb = $ref(instance.baseRole.driveCapacityMb); +let options_antennaLimit = $ref(instance.baseRole.antennaLimit); + +async function updateBaseRole() { + await os.apiWithDialog('admin/roles/update-default-role-override', { + options: { + gtlAvailable: options_gtlAvailable, + ltlAvailable: options_ltlAvailable, + canPublicNote: options_canPublicNote, + driveCapacityMb: options_driveCapacityMb, + antennaLimit: options_antennaLimit, + }, + }); +} + +function create() { + router.push('/admin/roles/new'); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.roles, + icon: 'ti ti-badges', +}))); +</script> + +<style lang="scss" module> + +</style> diff --git a/packages/frontend/src/pages/admin/settings.vue b/packages/frontend/src/pages/admin/settings.vue index 844ac62bdb..eae822c7c8 100644 --- a/packages/frontend/src/pages/admin/settings.vue +++ b/packages/frontend/src/pages/admin/settings.vue @@ -47,14 +47,6 @@ </FormSection> <FormSection> - <div class="_gaps_s"> - <MkSwitch v-model="enableLocalTimeline">{{ i18n.ts.enableLocalTimeline }}</MkSwitch> - <MkSwitch v-model="enableGlobalTimeline">{{ i18n.ts.enableGlobalTimeline }}</MkSwitch> - <FormInfo>{{ i18n.ts.disablingTimelinesInfo }}</FormInfo> - </div> - </FormSection> - - <FormSection> <template #label>{{ i18n.ts.theme }}</template> <div class="_gaps_m"> @@ -100,19 +92,11 @@ <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> </MkSwitch> - <FormSplit :min-width="280"> - <MkInput v-model="localDriveCapacityMb" type="number"> - <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template> - <template #suffix>MB</template> - <template #caption>{{ i18n.ts.inMb }}</template> - </MkInput> - - <MkInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles"> - <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> - <template #suffix>MB</template> - <template #caption>{{ i18n.ts.inMb }}</template> - </MkInput> - </FormSplit> + <MkInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles"> + <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </MkInput> </div> </FormSection> @@ -185,11 +169,8 @@ let backgroundImageUrl: string | null = $ref(null); let themeColor: any = $ref(null); let defaultLightTheme: any = $ref(null); let defaultDarkTheme: any = $ref(null); -let enableLocalTimeline: boolean = $ref(false); -let enableGlobalTimeline: boolean = $ref(false); let pinnedUsers: string = $ref(''); let cacheRemoteFiles: boolean = $ref(false); -let localDriveCapacityMb: any = $ref(0); let remoteDriveCapacityMb: any = $ref(0); let enableRegistration: boolean = $ref(false); let emailRequiredForSignup: boolean = $ref(false); @@ -212,11 +193,8 @@ async function init() { defaultDarkTheme = meta.defaultDarkTheme; maintainerName = meta.maintainerName; maintainerEmail = meta.maintainerEmail; - enableLocalTimeline = !meta.disableLocalTimeline; - enableGlobalTimeline = !meta.disableGlobalTimeline; pinnedUsers = meta.pinnedUsers.join('\n'); cacheRemoteFiles = meta.cacheRemoteFiles; - localDriveCapacityMb = meta.driveCapacityPerLocalUserMb; remoteDriveCapacityMb = meta.driveCapacityPerRemoteUserMb; enableRegistration = !meta.disableRegistration; emailRequiredForSignup = meta.emailRequiredForSignup; @@ -240,11 +218,8 @@ function save() { defaultDarkTheme: defaultDarkTheme === '' ? null : defaultDarkTheme, maintainerName, maintainerEmail, - disableLocalTimeline: !enableLocalTimeline, - disableGlobalTimeline: !enableGlobalTimeline, pinnedUsers: pinnedUsers.split('\n'), cacheRemoteFiles, - localDriveCapacityMb: parseInt(localDriveCapacityMb, 10), remoteDriveCapacityMb: parseInt(remoteDriveCapacityMb, 10), disableRegistration: !enableRegistration, emailRequiredForSignup, diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index babe76e4ec..fc1c1c1dc5 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -19,7 +19,6 @@ <option value="available">{{ i18n.ts.normal }}</option> <option value="admin">{{ i18n.ts.administrator }}</option> <option value="moderator">{{ i18n.ts.moderator }}</option> - <option value="silenced">{{ i18n.ts.silence }}</option> <option value="suspended">{{ i18n.ts.suspend }}</option> </MkSelect> <MkSelect v-model="origin" style="flex: 1;"> diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index c11a302260..5ed4dcddcf 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -34,8 +34,8 @@ import { definePageMetadata } from '@/scripts/page-metadata'; const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); -const isLocalTimelineAvailable = !instance.disableLocalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); -const isGlobalTimelineAvailable = !instance.disableGlobalTimeline || ($i != null && ($i.isModerator || $i.isAdmin)); +const isLocalTimelineAvailable = ($i == null && instance.baseRole.ltlAvailable) || ($i != null && $i.role.ltlAvailable); +const isGlobalTimelineAvailable = ($i == null && instance.baseRole.gtlAvailable) || ($i != null && $i.role.gtlAvailable); const keymap = { 't': focus, }; diff --git a/packages/frontend/src/pages/user-info.vue b/packages/frontend/src/pages/user-info.vue index 3d1742cb22..5bc98a56a7 100644 --- a/packages/frontend/src/pages/user-info.vue +++ b/packages/frontend/src/pages/user-info.vue @@ -87,18 +87,26 @@ </FormSection> </div> <div v-else-if="tab === 'moderation'" class="_gaps_m"> - <MkSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" @update:model-value="toggleModerator">{{ i18n.ts.moderator }}</MkSwitch> - <MkSwitch v-model="silenced" @update:model-value="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> <MkSwitch v-model="suspended" @update:model-value="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch> - {{ i18n.ts.reflectMayTakeTime }} <div> <MkButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton> <MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton> </div> - <MkTextarea v-model="moderationNote" manual-save> - <template #label>Moderation note</template> - </MkTextarea> <MkFolder> + <template #icon><i class="ti ti-badges"></i></template> + <template #label>{{ i18n.ts.roles }}</template> + + <div class="_gaps"> + <MkButton v-if="user.host == null && iAmModerator" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton> + + <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem"> + <MkRolePreview :class="$style.role" :role="role"/> + <button class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button> + </div> + </div> + </MkFolder> + <MkFolder> + <template #icon><i class="ti ti-password"></i></template> <template #label>IP</template> <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> @@ -110,21 +118,14 @@ </template> </MkFolder> <MkFolder> + <template #icon><i class="ti ti-cloud"></i></template> <template #label>{{ i18n.ts.files }}</template> <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> </MkFolder> - <FormSection> - <template #label>Drive Capacity Override</template> - - <MkInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride"> - <template #label>{{ i18n.ts.driveCapOverrideLabel }}</template> - <template #suffix>MB</template> - <template #caption> - {{ i18n.ts.driveCapOverrideCaption }} - </template> - </MkInput> - </FormSection> + <MkTextarea v-model="moderationNote" manual-save> + <template #label>Moderation note</template> + </MkTextarea> </div> <div v-else-if="tab === 'chart'" class="_gaps_m"> <div class="cmhjzshm"> @@ -180,12 +181,16 @@ import { definePageMetadata } from '@/scripts/page-metadata'; import { i18n } from '@/i18n'; import { iAmAdmin, iAmModerator } from '@/account'; import { instance } from '@/instance'; +import MkRolePreview from '@/components/MkRolePreview.vue'; -const props = defineProps<{ +const props = withDefaults(defineProps<{ userId: string; -}>(); + initialTab?: string; +}>(), { + initialTab: 'overview', +}); -let tab = $ref('overview'); +let tab = $ref(props.initialTab); let chartSrc = $ref('per-user-notes'); let user = $ref<null | misskey.entities.UserDetailed>(); let init = $ref<ReturnType<typeof createFetcher>>(); @@ -195,7 +200,6 @@ let ap = $ref(null); let moderator = $ref(false); let silenced = $ref(false); let suspended = $ref(false); -let driveCapacityOverrideMb: number | null = $ref(0); let moderationNote = $ref(''); const filesPagination = { endpoint: 'admin/drive/files' as const, @@ -220,7 +224,6 @@ function createFetcher() { moderator = info.isModerator; silenced = info.isSilenced; suspended = info.isSuspended; - driveCapacityOverrideMb = user.driveCapacityOverrideMb; moderationNote = info.moderationNote; watch($$(moderationNote), async () => { @@ -257,19 +260,6 @@ async function resetPassword() { }); } -async function toggleSilence(v) { - const confirm = await os.confirm({ - type: 'warning', - text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm, - }); - if (confirm.canceled) { - silenced = !v; - } else { - await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id }); - await refreshUser(); - } -} - async function toggleSuspend(v) { const confirm = await os.confirm({ type: 'warning', @@ -283,11 +273,6 @@ async function toggleSuspend(v) { } } -async function toggleModerator(v) { - await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id }); - await refreshUser(); -} - async function deleteAllFiles() { const confirm = await os.confirm({ type: 'warning', @@ -307,22 +292,6 @@ async function deleteAllFiles() { await refreshUser(); } -async function applyDriveCapacityOverride() { - let driveCapOrMb = driveCapacityOverrideMb; - if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) { - driveCapOrMb = null; - } - try { - await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb }); - await refreshUser(); - } catch (err) { - os.alert({ - type: 'error', - text: err.toString(), - }); - } -} - async function deleteAccount() { const confirm = await os.confirm({ type: 'warning', @@ -347,6 +316,31 @@ async function deleteAccount() { } } +async function assignRole() { + const roles = await os.api('admin/roles/list'); + + const { canceled, result: roleId } = await os.select({ + title: i18n.ts._role.chooseRoleToAssign, + items: roles.map(r => ({ text: r.name, value: r.id })), + }); + if (canceled) return; + + await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id }); + refreshUser(); +} + +async function unassignRole(role, ev) { + os.popupMenu([{ + text: i18n.ts.unassign, + icon: 'ti ti-x', + danger: true, + action: async () => { + await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.id }); + refreshUser(); + }, + }], ev.currentTarget ?? ev.target); +} + watch(() => props.userId, () => { init = createFetcher(); }, { @@ -484,4 +478,19 @@ definePageMetadata(computed(() => ({ margin-left: auto; } } + +.roleItem { + display: flex; +} + +.role { + flex: 1; +} + +.roleUnassign { + width: 32px; + height: 32px; + margin-left: 8px; + align-self: center; +} </style> diff --git a/packages/frontend/src/pages/user/home.vue b/packages/frontend/src/pages/user/home.vue index 07d34a794d..eea4d20094 100644 --- a/packages/frontend/src/pages/user/home.vue +++ b/packages/frontend/src/pages/user/home.vue @@ -18,7 +18,6 @@ <div class="bottom"> <span class="username"><MkAcct :user="user" :detail="true"/></span> <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> - <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> </div> @@ -35,7 +34,6 @@ <div class="bottom"> <span class="username"><MkAcct :user="user" :detail="true"/></span> <span v-if="user.isAdmin" :title="i18n.ts.isAdmin" style="color: var(--badge);"><i class="ti ti-shield"></i></span> - <span v-if="!user.isAdmin && user.isModerator" :title="i18n.ts.isModerator" style="color: var(--badge);"><i class="ti ti-shield"></i></span> <span v-if="user.isLocked" :title="i18n.ts.isLocked"><i class="ti ti-lock"></i></span> <span v-if="user.isBot" :title="i18n.ts.isBot"><i class="ti ti-robot"></i></span> </div> @@ -189,7 +187,7 @@ onMounted(() => { const bd = parseInt(props.user.birthday.split('-')[2]); if (m === bm && d === bd) { confetti({ - duration: 1000 * 4 + duration: 1000 * 4, }); } } diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 4b9f49f8fd..05dcd7806e 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -37,6 +37,7 @@ export const routes = [{ }, { path: '/user-info/:userId', component: page(() => import('./pages/user-info.vue')), + hash: 'initialTab', }, { path: '/instance-info/:host', component: page(() => import('./pages/instance-info.vue')), @@ -352,6 +353,22 @@ export const routes = [{ name: 'ads', component: page(() => import('./pages/admin/ads.vue')), }, { + path: '/roles/:id/edit', + name: 'roles', + component: page(() => import('./pages/admin/roles.edit.vue')), + }, { + path: '/roles/new', + name: 'roles', + component: page(() => import('./pages/admin/roles.edit.vue')), + }, { + path: '/roles/:id', + name: 'roles', + component: page(() => import('./pages/admin/roles.role.vue')), + }, { + path: '/roles', + name: 'roles', + component: page(() => import('./pages/admin/roles.vue')), + }, { path: '/database', name: 'database', component: page(() => import('./pages/admin/database.vue')), diff --git a/packages/frontend/src/scripts/get-user-menu.ts b/packages/frontend/src/scripts/get-user-menu.ts index 7ede64c327..74bd61fd78 100644 --- a/packages/frontend/src/scripts/get-user-menu.ts +++ b/packages/frontend/src/scripts/get-user-menu.ts @@ -108,26 +108,6 @@ export function getUserMenu(user, router: Router = mainRouter) { }); } - async function toggleSilence() { - if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; - - os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { - userId: user.id, - }).then(() => { - user.isSilenced = !user.isSilenced; - }); - } - - async function toggleSuspend() { - if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; - - os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { - userId: user.id, - }).then(() => { - user.isSuspended = !user.isSuspended; - }); - } - function reportAbuse() { os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { user: user, @@ -218,13 +198,11 @@ export function getUserMenu(user, router: Router = mainRouter) { if (iAmModerator) { menu = menu.concat([null, { - icon: 'ti ti-microphone-2-off', - text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence, - action: toggleSilence, - }, { - icon: 'ti ti-snowflake', - text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend, - action: toggleSuspend, + icon: 'ti ti-user-exclamation', + text: i18n.ts.moderation, + action: () => { + router.push('/user-info/' + user.id + '#moderation'); + }, }]); } } diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue index f75e526939..b8a0a504a3 100644 --- a/packages/frontend/src/ui/deck/tl-column.vue +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -45,9 +45,7 @@ onMounted(() => { if (props.column.tl == null) { setType(); } else if ($i) { - disabled = !$i.isModerator && !$i.isAdmin && ( - instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) || - instance.disableGlobalTimeline && ['global'].includes(props.column.tl)); + disabled = false; // TODO } }); |