feat: moderation note
This commit is contained in:
		
							parent
							
								
									0de973d293
								
							
						
					
					
						commit
						dd426735a0
					
				
					 8 changed files with 146 additions and 44 deletions
				
			
		|  | @ -25,6 +25,7 @@ You should also include the user name that made the change. | ||||||
| - Client: Add rss-marquee widget @syuilo | - Client: Add rss-marquee widget @syuilo | ||||||
| - Client: Removing entries from a clip @futchitwo | - Client: Removing entries from a clip @futchitwo | ||||||
| - Client: Poll highlights in explore page @syuilo | - Client: Poll highlights in explore page @syuilo | ||||||
|  | - ユーザーにモデレーションメモを残せる機能 @syuilo | ||||||
| - Make possible to delete an account by admin @syuilo | - Make possible to delete an account by admin @syuilo | ||||||
| - Improve player detection in URL preview @mei23 | - Improve player detection in URL preview @mei23 | ||||||
| - Add Badge Image to Push Notification #8012 @tamaina | - Add Badge Image to Push Notification #8012 @tamaina | ||||||
|  |  | ||||||
|  | @ -381,6 +381,7 @@ administrator: "管理者" | ||||||
| token: "トークン" | token: "トークン" | ||||||
| twoStepAuthentication: "二段階認証" | twoStepAuthentication: "二段階認証" | ||||||
| moderator: "モデレーター" | moderator: "モデレーター" | ||||||
|  | moderation: "モデレーション" | ||||||
| nUsersMentioned: "{n}人が投稿" | nUsersMentioned: "{n}人が投稿" | ||||||
| securityKey: "セキュリティキー" | securityKey: "セキュリティキー" | ||||||
| securityKeyName: "キーの名前" | securityKeyName: "キーの名前" | ||||||
|  |  | ||||||
|  | @ -0,0 +1,11 @@ | ||||||
|  | export class userModerationNote1656772790599 { | ||||||
|  |     name = 'userModerationNote1656772790599' | ||||||
|  | 
 | ||||||
|  |     async up(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TABLE "user_profile" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     async down(queryRunner) { | ||||||
|  |         await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "moderationNote"`); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -1,8 +1,8 @@ | ||||||
| import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; | import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; | ||||||
|  | import { ffVisibility, notificationTypes } from '@/types.js'; | ||||||
| import { id } from '../id.js'; | import { id } from '../id.js'; | ||||||
| import { User } from './user.js'; | import { User } from './user.js'; | ||||||
| import { Page } from './page.js'; | import { Page } from './page.js'; | ||||||
| import { ffVisibility, notificationTypes } from '@/types.js'; |  | ||||||
| 
 | 
 | ||||||
| // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
 | // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
 | ||||||
| //       ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
 | //       ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
 | ||||||
|  | @ -117,6 +117,11 @@ export class UserProfile { | ||||||
| 	}) | 	}) | ||||||
| 	public password: string | null; | 	public password: string | null; | ||||||
| 
 | 
 | ||||||
|  | 	@Column('varchar', { | ||||||
|  | 		length: 8192, default: '', | ||||||
|  | 	}) | ||||||
|  | 	public moderationNote: string | null; | ||||||
|  | 
 | ||||||
| 	// TODO: そのうち消す
 | 	// TODO: そのうち消す
 | ||||||
| 	@Column('jsonb', { | 	@Column('jsonb', { | ||||||
| 		default: {}, | 		default: {}, | ||||||
|  |  | ||||||
|  | @ -61,6 +61,7 @@ import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; | ||||||
| import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; | import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; | ||||||
| import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; | import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; | ||||||
| import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; | import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; | ||||||
|  | import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js'; | ||||||
| import * as ep___announcements from './endpoints/announcements.js'; | import * as ep___announcements from './endpoints/announcements.js'; | ||||||
| import * as ep___antennas_create from './endpoints/antennas/create.js'; | import * as ep___antennas_create from './endpoints/antennas/create.js'; | ||||||
| import * as ep___antennas_delete from './endpoints/antennas/delete.js'; | import * as ep___antennas_delete from './endpoints/antennas/delete.js'; | ||||||
|  | @ -376,6 +377,7 @@ const eps = [ | ||||||
| 	['admin/update-meta', ep___admin_updateMeta], | 	['admin/update-meta', ep___admin_updateMeta], | ||||||
| 	['admin/vacuum', ep___admin_vacuum], | 	['admin/vacuum', ep___admin_vacuum], | ||||||
| 	['admin/delete-account', ep___admin_deleteAccount], | 	['admin/delete-account', ep___admin_deleteAccount], | ||||||
|  | 	['admin/update-user-note', ep___admin_updateUserNote], | ||||||
| 	['announcements', ep___announcements], | 	['announcements', ep___announcements], | ||||||
| 	['antennas/create', ep___antennas_create], | 	['antennas/create', ep___antennas_create], | ||||||
| 	['antennas/delete', ep___antennas_delete], | 	['antennas/delete', ep___antennas_delete], | ||||||
|  |  | ||||||
|  | @ -69,6 +69,7 @@ export default define(meta, paramDef, async (ps, me) => { | ||||||
| 		isSilenced: user.isSilenced, | 		isSilenced: user.isSilenced, | ||||||
| 		isSuspended: user.isSuspended, | 		isSuspended: user.isSuspended, | ||||||
| 		lastActiveDate: user.lastActiveDate, | 		lastActiveDate: user.lastActiveDate, | ||||||
|  | 		moderationNote: profile.moderationNote, | ||||||
| 		signins, | 		signins, | ||||||
| 	}; | 	}; | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,31 @@ | ||||||
|  | import { UserProfiles, Users } from '@/models/index.js'; | ||||||
|  | import define from '../../define.js'; | ||||||
|  | 
 | ||||||
|  | export const meta = { | ||||||
|  | 	tags: ['admin'], | ||||||
|  | 
 | ||||||
|  | 	requireCredential: true, | ||||||
|  | 	requireModerator: true, | ||||||
|  | } as const; | ||||||
|  | 
 | ||||||
|  | export const paramDef = { | ||||||
|  | 	type: 'object', | ||||||
|  | 	properties: { | ||||||
|  | 		userId: { type: 'string', format: 'misskey:id' }, | ||||||
|  | 		text: { type: 'string' }, | ||||||
|  | 	}, | ||||||
|  | 	required: ['userId', 'text'], | ||||||
|  | } as const; | ||||||
|  | 
 | ||||||
|  | // eslint-disable-next-line import/no-default-export
 | ||||||
|  | export default define(meta, paramDef, async (ps, me) => { | ||||||
|  | 	const user = await Users.findOneBy({ id: ps.userId }); | ||||||
|  | 
 | ||||||
|  | 	if (user == null) { | ||||||
|  | 		throw new Error('user not found'); | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	await UserProfiles.update({ userId: user.id }, { | ||||||
|  | 		moderationNote: ps.text, | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
|  | @ -9,6 +9,11 @@ | ||||||
| 					<div class="body"> | 					<div class="body"> | ||||||
| 						<span class="name"><MkUserName class="name" :user="user"/></span> | 						<span class="name"><MkUserName class="name" :user="user"/></span> | ||||||
| 						<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> | 						<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> | ||||||
|  | 						<span class="state"> | ||||||
|  | 							<span v-if="suspended" class="suspended">Suspended</span> | ||||||
|  | 							<span v-if="silenced" class="silenced">Silenced</span> | ||||||
|  | 							<span v-if="moderator" class="moderator">Moderator</span> | ||||||
|  | 						</span> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 
 | 
 | ||||||
|  | @ -41,20 +46,12 @@ | ||||||
| 						<template #key>{{ i18n.ts.lastActiveDate }}</template> | 						<template #key>{{ i18n.ts.lastActiveDate }}</template> | ||||||
| 						<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> | 						<template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> | ||||||
| 					</MkKeyValue> | 					</MkKeyValue> | ||||||
|  | 					<MkKeyValue v-if="info" oneline style="margin: 1em 0;"> | ||||||
|  | 						<template #key>{{ i18n.ts.email }}</template> | ||||||
|  | 						<template #value><span class="_monospace">{{ info.email }}</span></template> | ||||||
|  | 					</MkKeyValue> | ||||||
| 				</div> | 				</div> | ||||||
| 
 | 
 | ||||||
| 				<FormSection v-if="iAmModerator"> |  | ||||||
| 					<template #label>Moderation</template> |  | ||||||
| 					<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch> |  | ||||||
| 					<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch> |  | ||||||
| 					<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch> |  | ||||||
| 					{{ $ts.reflectMayTakeTime }} |  | ||||||
| 					<div class="_formBlock"> |  | ||||||
| 						<FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> |  | ||||||
| 						<FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ $ts.deleteAccount }}</FormButton> |  | ||||||
| 					</div> |  | ||||||
| 				</FormSection> |  | ||||||
| 
 |  | ||||||
| 				<FormSection> | 				<FormSection> | ||||||
| 					<template #label>ActivityPub</template> | 					<template #label>ActivityPub</template> | ||||||
| 
 | 
 | ||||||
|  | @ -78,8 +75,44 @@ | ||||||
| 					</div> | 					</div> | ||||||
| 
 | 
 | ||||||
| 					<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> | 					<FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> | ||||||
|  | 
 | ||||||
|  | 					<FormFolder class="_formBlock"> | ||||||
|  | 						<template #label>Raw</template> | ||||||
|  | 
 | ||||||
|  | 						<MkObjectView v-if="ap" tall :value="ap"> | ||||||
|  | 						</MkObjectView> | ||||||
|  | 					</FormFolder> | ||||||
| 				</FormSection> | 				</FormSection> | ||||||
| 			</div> | 			</div> | ||||||
|  | 			<div v-else-if="tab === 'moderation'" class="_formRoot"> | ||||||
|  | 				<FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch> | ||||||
|  | 				<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch> | ||||||
|  | 				<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch> | ||||||
|  | 				{{ $ts.reflectMayTakeTime }} | ||||||
|  | 				<div class="_formBlock"> | ||||||
|  | 					<FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> | ||||||
|  | 					<FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ $ts.deleteAccount }}</FormButton> | ||||||
|  | 				</div> | ||||||
|  | 				<FormTextarea v-model="moderationNote" manual-save class="_formBlock"> | ||||||
|  | 					<template #label>Moderation note</template> | ||||||
|  | 				</FormTextarea> | ||||||
|  | 				<FormFolder class="_formBlock"> | ||||||
|  | 					<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> | ||||||
|  | 					<template v-if="iAmAdmin && ips"> | ||||||
|  | 						<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> | ||||||
|  | 							<span class="date">{{ record.createdAt }}</span> | ||||||
|  | 							<span class="ip">{{ record.ip }}</span> | ||||||
|  | 						</div> | ||||||
|  | 					</template> | ||||||
|  | 				</FormFolder> | ||||||
|  | 				<FormFolder class="_formBlock"> | ||||||
|  | 					<template #label>{{ i18n.ts.files }}</template> | ||||||
|  | 
 | ||||||
|  | 					<MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> | ||||||
|  | 				</FormFolder> | ||||||
|  | 			</div> | ||||||
| 			<div v-else-if="tab === 'chart'" class="_formRoot"> | 			<div v-else-if="tab === 'chart'" class="_formRoot"> | ||||||
| 				<div class="cmhjzshm"> | 				<div class="cmhjzshm"> | ||||||
| 					<div class="selects"> | 					<div class="selects"> | ||||||
|  | @ -95,23 +128,6 @@ | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div v-else-if="tab === 'files'" class="_formRoot"> |  | ||||||
| 				<MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> |  | ||||||
| 			</div> |  | ||||||
| 			<div v-else-if="tab === 'ip'" class="_formRoot"> |  | ||||||
| 				<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> |  | ||||||
| 				<MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> |  | ||||||
| 				<template v-if="iAmAdmin && ips"> |  | ||||||
| 					<div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> |  | ||||||
| 						<span class="date">{{ record.createdAt }}</span> |  | ||||||
| 						<span class="ip">{{ record.ip }}</span> |  | ||||||
| 					</div> |  | ||||||
| 				</template> |  | ||||||
| 			</div> |  | ||||||
| 			<div v-else-if="tab === 'ap'" class="_formRoot"> |  | ||||||
| 				<MkObjectView v-if="ap" tall :value="ap"> |  | ||||||
| 				</MkObjectView> |  | ||||||
| 			</div> |  | ||||||
| 			<div v-else-if="tab === 'raw'" class="_formRoot"> | 			<div v-else-if="tab === 'raw'" class="_formRoot"> | ||||||
| 				<MkObjectView v-if="info && $i.isAdmin" tall :value="info"> | 				<MkObjectView v-if="info && $i.isAdmin" tall :value="info"> | ||||||
| 				</MkObjectView> | 				</MkObjectView> | ||||||
|  | @ -134,6 +150,7 @@ import FormSwitch from '@/components/form/switch.vue'; | ||||||
| import FormLink from '@/components/form/link.vue'; | import FormLink from '@/components/form/link.vue'; | ||||||
| import FormSection from '@/components/form/section.vue'; | import FormSection from '@/components/form/section.vue'; | ||||||
| import FormButton from '@/components/ui/button.vue'; | import FormButton from '@/components/ui/button.vue'; | ||||||
|  | import FormFolder from '@/components/form/folder.vue'; | ||||||
| import MkKeyValue from '@/components/key-value.vue'; | import MkKeyValue from '@/components/key-value.vue'; | ||||||
| import MkSelect from '@/components/form/select.vue'; | import MkSelect from '@/components/form/select.vue'; | ||||||
| import FormSuspense from '@/components/form/suspense.vue'; | import FormSuspense from '@/components/form/suspense.vue'; | ||||||
|  | @ -162,6 +179,7 @@ let ap = $ref(null); | ||||||
| let moderator = $ref(false); | let moderator = $ref(false); | ||||||
| let silenced = $ref(false); | let silenced = $ref(false); | ||||||
| let suspended = $ref(false); | let suspended = $ref(false); | ||||||
|  | let moderationNote = $ref(''); | ||||||
| const filesPagination = { | const filesPagination = { | ||||||
| 	endpoint: 'admin/drive/files' as const, | 	endpoint: 'admin/drive/files' as const, | ||||||
| 	limit: 10, | 	limit: 10, | ||||||
|  | @ -185,6 +203,12 @@ function createFetcher() { | ||||||
| 			moderator = info.isModerator; | 			moderator = info.isModerator; | ||||||
| 			silenced = info.isSilenced; | 			silenced = info.isSilenced; | ||||||
| 			suspended = info.isSuspended; | 			suspended = info.isSuspended; | ||||||
|  | 			moderationNote = info.moderationNote; | ||||||
|  | 
 | ||||||
|  | 			watch($$(moderationNote), async () => { | ||||||
|  | 				await os.api('admin/update-user-note', { userId: user.id, text: moderationNote }); | ||||||
|  | 				await refreshUser(); | ||||||
|  | 			}); | ||||||
| 		}); | 		}); | ||||||
| 	} else { | 	} else { | ||||||
| 		return () => os.api('users/show', { | 		return () => os.api('users/show', { | ||||||
|  | @ -309,23 +333,15 @@ const headerTabs = $computed(() => [{ | ||||||
| 	key: 'overview', | 	key: 'overview', | ||||||
| 	title: i18n.ts.overview, | 	title: i18n.ts.overview, | ||||||
| 	icon: 'fas fa-info-circle', | 	icon: 'fas fa-info-circle', | ||||||
| }, { | }, iAmModerator ? { | ||||||
|  | 	key: 'moderation', | ||||||
|  | 	title: i18n.ts.moderation, | ||||||
|  | 	icon: 'fas fa-shield-halved', | ||||||
|  | } : null, { | ||||||
| 	key: 'chart', | 	key: 'chart', | ||||||
| 	title: i18n.ts.charts, | 	title: i18n.ts.charts, | ||||||
| 	icon: 'fas fa-chart-simple', | 	icon: 'fas fa-chart-simple', | ||||||
| }, iAmModerator ? { | }, { | ||||||
| 	key: 'files', |  | ||||||
| 	title: i18n.ts.files, |  | ||||||
| 	icon: 'fas fa-cloud', |  | ||||||
| } : null, { |  | ||||||
| 	key: 'ap', |  | ||||||
| 	title: 'AP', |  | ||||||
| 	icon: 'fas fa-share-alt', |  | ||||||
| }, iAmModerator ? { |  | ||||||
| 	key: 'ip', |  | ||||||
| 	title: 'IP', |  | ||||||
| 	icon: 'fas fa-bars-staggered', |  | ||||||
| } : null, { |  | ||||||
| 	key: 'raw', | 	key: 'raw', | ||||||
| 	title: 'Raw', | 	title: 'Raw', | ||||||
| 	icon: 'fas fa-code', | 	icon: 'fas fa-code', | ||||||
|  | @ -370,6 +386,40 @@ definePageMetadata(computed(() => ({ | ||||||
| 			overflow: hidden; | 			overflow: hidden; | ||||||
| 			text-overflow: ellipsis; | 			text-overflow: ellipsis; | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		> .state { | ||||||
|  | 			display: flex; | ||||||
|  | 			gap: 8px; | ||||||
|  | 			flex-wrap: wrap; | ||||||
|  | 			margin-top: 4px; | ||||||
|  | 
 | ||||||
|  | 			&:empty { | ||||||
|  | 				display: none; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			> .suspended, > .silenced, > .moderator { | ||||||
|  | 				display: inline-block; | ||||||
|  | 				border: solid 1px; | ||||||
|  | 				border-radius: 6px; | ||||||
|  | 				padding: 2px 6px; | ||||||
|  | 				font-size: 85%; | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			> .suspended { | ||||||
|  | 				color: var(--error); | ||||||
|  | 				border-color: var(--error); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			> .silenced { | ||||||
|  | 				color: var(--warn); | ||||||
|  | 				border-color: var(--warn); | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			> .moderator { | ||||||
|  | 				color: var(--success); | ||||||
|  | 				border-color: var(--success); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue