feat(role): リモートのアイコンとバナーの変更をロールで制限できるように (MisskeyIO#374)
This commit is contained in:
parent
e0c0a2a0b0
commit
65382dc70b
@ -1620,6 +1620,8 @@ _role:
|
|||||||
canCreateContent: "Can create contents"
|
canCreateContent: "Can create contents"
|
||||||
canUpdateContent: "Can edit contents"
|
canUpdateContent: "Can edit contents"
|
||||||
canDeleteContent: "Can delete contents"
|
canDeleteContent: "Can delete contents"
|
||||||
|
canUpdateAvatar: "Can change avatar"
|
||||||
|
canUpdateBanner: "Can change banner"
|
||||||
canInvite: "Can create instance invite codes"
|
canInvite: "Can create instance invite codes"
|
||||||
inviteLimit: "Invite limit"
|
inviteLimit: "Invite limit"
|
||||||
inviteLimitCycle: "Invite limit cooldown"
|
inviteLimitCycle: "Invite limit cooldown"
|
||||||
|
8
locales/index.d.ts
vendored
8
locales/index.d.ts
vendored
@ -6456,6 +6456,14 @@ export interface Locale extends ILocale {
|
|||||||
* コンテンツの削除
|
* コンテンツの削除
|
||||||
*/
|
*/
|
||||||
"canDeleteContent": string;
|
"canDeleteContent": string;
|
||||||
|
/**
|
||||||
|
* アイコンの変更
|
||||||
|
*/
|
||||||
|
"canUpdateAvatar": string;
|
||||||
|
/**
|
||||||
|
* バナーの変更
|
||||||
|
*/
|
||||||
|
"canUpdateBanner": string;
|
||||||
/**
|
/**
|
||||||
* サーバー招待コードの発行
|
* サーバー招待コードの発行
|
||||||
*/
|
*/
|
||||||
|
@ -1671,6 +1671,8 @@ _role:
|
|||||||
canCreateContent: "コンテンツの作成"
|
canCreateContent: "コンテンツの作成"
|
||||||
canUpdateContent: "コンテンツの編集"
|
canUpdateContent: "コンテンツの編集"
|
||||||
canDeleteContent: "コンテンツの削除"
|
canDeleteContent: "コンテンツの削除"
|
||||||
|
canUpdateAvatar: "アイコンの変更"
|
||||||
|
canUpdateBanner: "バナーの変更"
|
||||||
canInvite: "サーバー招待コードの発行"
|
canInvite: "サーバー招待コードの発行"
|
||||||
inviteLimit: "招待コードの作成可能数"
|
inviteLimit: "招待コードの作成可能数"
|
||||||
inviteLimitCycle: "招待コードの発行間隔"
|
inviteLimitCycle: "招待コードの発行間隔"
|
||||||
|
@ -1650,6 +1650,11 @@ _role:
|
|||||||
gtlAvailable: "글로벌 타임라인 보이기"
|
gtlAvailable: "글로벌 타임라인 보이기"
|
||||||
ltlAvailable: "로컬 타임라인 보이기"
|
ltlAvailable: "로컬 타임라인 보이기"
|
||||||
canPublicNote: "공개 노트 허용"
|
canPublicNote: "공개 노트 허용"
|
||||||
|
canCreateContent: "컨텐츠 생성 허용"
|
||||||
|
canUpdateContent: "컨텐츠 수정 허용"
|
||||||
|
canDeleteContent: "컨텐츠 삭제 허용"
|
||||||
|
canUpdateAvatar: "아바타 변경 허용"
|
||||||
|
canUpdateBanner: "배너 변경 허용"
|
||||||
canInvite: "서버 초대 코드 발행"
|
canInvite: "서버 초대 코드 발행"
|
||||||
inviteLimit: "초대 한도"
|
inviteLimit: "초대 한도"
|
||||||
inviteLimitCycle: "초대 발급 간격"
|
inviteLimitCycle: "초대 발급 간격"
|
||||||
|
@ -39,6 +39,8 @@ export type RolePolicies = {
|
|||||||
canCreateContent: boolean;
|
canCreateContent: boolean;
|
||||||
canUpdateContent: boolean;
|
canUpdateContent: boolean;
|
||||||
canDeleteContent: boolean;
|
canDeleteContent: boolean;
|
||||||
|
canUpdateAvatar: boolean;
|
||||||
|
canUpdateBanner: boolean;
|
||||||
canInvite: boolean;
|
canInvite: boolean;
|
||||||
inviteLimit: number;
|
inviteLimit: number;
|
||||||
inviteLimitCycle: number;
|
inviteLimitCycle: number;
|
||||||
@ -70,6 +72,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||||||
canCreateContent: true,
|
canCreateContent: true,
|
||||||
canUpdateContent: true,
|
canUpdateContent: true,
|
||||||
canDeleteContent: true,
|
canDeleteContent: true,
|
||||||
|
canUpdateAvatar: true,
|
||||||
|
canUpdateBanner: true,
|
||||||
canInvite: false,
|
canInvite: false,
|
||||||
inviteLimit: 0,
|
inviteLimit: 0,
|
||||||
inviteLimitCycle: 60 * 24 * 7,
|
inviteLimitCycle: 60 * 24 * 7,
|
||||||
@ -337,6 +341,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||||||
canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)),
|
canCreateContent: calc('canCreateContent', vs => vs.some(v => v === true)),
|
||||||
canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)),
|
canUpdateContent: calc('canUpdateContent', vs => vs.some(v => v === true)),
|
||||||
canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)),
|
canDeleteContent: calc('canDeleteContent', vs => vs.some(v => v === true)),
|
||||||
|
canUpdateAvatar: calc('canUpdateAvatar', vs => vs.some(v => v === true)),
|
||||||
|
canUpdateBanner: calc('canUpdateBanner', vs => vs.some(v => v === true)),
|
||||||
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
|
||||||
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
|
||||||
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
|
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
|
||||||
|
@ -20,6 +20,7 @@ import type Logger from '@/logger.js';
|
|||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import type { IdService } from '@/core/IdService.js';
|
import type { IdService } from '@/core/IdService.js';
|
||||||
import type { MfmService } from '@/core/MfmService.js';
|
import type { MfmService } from '@/core/MfmService.js';
|
||||||
|
import type { RoleService } from '@/core/RoleService.js';
|
||||||
import { toArray } from '@/misc/prelude/array.js';
|
import { toArray } from '@/misc/prelude/array.js';
|
||||||
import type { GlobalEventService } from '@/core/GlobalEventService.js';
|
import type { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
import type { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||||
@ -75,6 +76,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
private instanceChart: InstanceChart;
|
private instanceChart: InstanceChart;
|
||||||
private apLoggerService: ApLoggerService;
|
private apLoggerService: ApLoggerService;
|
||||||
private accountMoveService: AccountMoveService;
|
private accountMoveService: AccountMoveService;
|
||||||
|
private roleService: RoleService;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -123,6 +125,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
this.instanceChart = this.moduleRef.get('InstanceChart');
|
this.instanceChart = this.moduleRef.get('InstanceChart');
|
||||||
this.apLoggerService = this.moduleRef.get('ApLoggerService');
|
this.apLoggerService = this.moduleRef.get('ApLoggerService');
|
||||||
this.accountMoveService = this.moduleRef.get('AccountMoveService');
|
this.accountMoveService = this.moduleRef.get('AccountMoveService');
|
||||||
|
this.roleService = this.moduleRef.get('RoleService');
|
||||||
this.logger = this.apLoggerService.logger;
|
this.logger = this.apLoggerService.logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -462,6 +465,8 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
throw new Error('unexpected schema of person url: ' + url);
|
throw new Error('unexpected schema of person url: ' + url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policy = await this.roleService.getUserPolicies(exist.id);
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
lastFetchedAt: new Date(),
|
lastFetchedAt: new Date(),
|
||||||
inbox: person.inbox,
|
inbox: person.inbox,
|
||||||
@ -477,7 +482,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
movedToUri: person.movedTo ?? null,
|
movedToUri: person.movedTo ?? null,
|
||||||
alsoKnownAs: person.alsoKnownAs ?? null,
|
alsoKnownAs: person.alsoKnownAs ?? null,
|
||||||
isExplorable: person.discoverable,
|
isExplorable: person.discoverable,
|
||||||
...(await this.resolveAvatarAndBanner(exist, person.icon, person.image).catch(() => ({}))),
|
...((policy.canUpdateAvatar || policy.canUpdateBanner) ? await this.resolveAvatarAndBanner(exist, policy.canUpdateAvatar ? person.icon : exist.avatarUrl, policy.canUpdateBanner ? person.image : exist.bannerUrl).catch(() => ({})) : {}),
|
||||||
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
} as Partial<MiRemoteUser> & Pick<MiRemoteUser, 'isBot' | 'isCat' | 'isLocked' | 'movedToUri' | 'alsoKnownAs' | 'isExplorable'>;
|
||||||
|
|
||||||
const moving = ((): boolean => {
|
const moving = ((): boolean => {
|
||||||
|
@ -233,6 +233,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
|
|
||||||
const updates = {} as Partial<MiUser>;
|
const updates = {} as Partial<MiUser>;
|
||||||
const profileUpdates = {} as Partial<MiUserProfile>;
|
const profileUpdates = {} as Partial<MiUserProfile>;
|
||||||
|
const policy = await this.roleService.getUserPolicies(user.id);
|
||||||
|
|
||||||
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
|
||||||
|
|
||||||
@ -245,7 +246,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
|
if (ps.followersVisibility !== undefined) profileUpdates.followersVisibility = ps.followersVisibility;
|
||||||
if (ps.mutedWords !== undefined) {
|
if (ps.mutedWords !== undefined) {
|
||||||
const length = ps.mutedWords.length;
|
const length = ps.mutedWords.length;
|
||||||
if (length > (await this.roleService.getUserPolicies(user.id)).wordMuteLimit) {
|
if (length > policy.wordMuteLimit) {
|
||||||
throw new ApiError(meta.errors.tooManyMutedWords);
|
throw new ApiError(meta.errors.tooManyMutedWords);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,13 +280,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
|
if (typeof ps.injectFeaturedNote === 'boolean') profileUpdates.injectFeaturedNote = ps.injectFeaturedNote;
|
||||||
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
|
if (typeof ps.receiveAnnouncementEmail === 'boolean') profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail;
|
||||||
if (typeof ps.alwaysMarkNsfw === 'boolean') {
|
if (typeof ps.alwaysMarkNsfw === 'boolean') {
|
||||||
if ((await roleService.getUserPolicies(user.id)).alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
|
if (policy.alwaysMarkNsfw) throw new ApiError(meta.errors.restrictedByRole);
|
||||||
profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
|
profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw;
|
||||||
}
|
}
|
||||||
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
|
if (typeof ps.autoSensitive === 'boolean') profileUpdates.autoSensitive = ps.autoSensitive;
|
||||||
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
|
if (ps.emailNotificationTypes !== undefined) profileUpdates.emailNotificationTypes = ps.emailNotificationTypes;
|
||||||
|
|
||||||
if (ps.avatarId) {
|
if (ps.avatarId) {
|
||||||
|
if (!policy.canUpdateAvatar) throw new ApiError(meta.errors.restrictedByRole);
|
||||||
const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId });
|
const avatar = await this.driveFilesRepository.findOneBy({ id: ps.avatarId });
|
||||||
|
|
||||||
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
|
if (avatar == null || avatar.userId !== user.id) throw new ApiError(meta.errors.noSuchAvatar);
|
||||||
@ -301,6 +303,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ps.bannerId) {
|
if (ps.bannerId) {
|
||||||
|
if (!policy.canUpdateBanner) throw new ApiError(meta.errors.restrictedByRole);
|
||||||
const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
|
const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
|
||||||
|
|
||||||
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
|
if (banner == null || banner.userId !== user.id) throw new ApiError(meta.errors.noSuchBanner);
|
||||||
@ -317,13 +320,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
|
|
||||||
if (ps.avatarDecorations) {
|
if (ps.avatarDecorations) {
|
||||||
const decorations = await this.avatarDecorationService.getAll(true);
|
const decorations = await this.avatarDecorationService.getAll(true);
|
||||||
const [myRoles, myPolicies] = await Promise.all([this.roleService.getUserRoles(user.id), this.roleService.getUserPolicies(user.id)]);
|
const myRoles = await this.roleService.getUserRoles(user.id);
|
||||||
const allRoles = await this.roleService.getRoles();
|
const allRoles = await this.roleService.getRoles();
|
||||||
const decorationIds = decorations
|
const decorationIds = decorations
|
||||||
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
|
.filter(d => d.roleIdsThatCanBeUsedThisDecoration.filter(roleId => allRoles.some(r => r.id === roleId)).length === 0 || myRoles.some(r => d.roleIdsThatCanBeUsedThisDecoration.includes(r.id)))
|
||||||
.map(d => d.id);
|
.map(d => d.id);
|
||||||
|
|
||||||
if (ps.avatarDecorations.length > myPolicies.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole);
|
if (ps.avatarDecorations.length > policy.avatarDecorationLimit) throw new ApiError(meta.errors.restrictedByRole);
|
||||||
|
|
||||||
updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
|
updates.avatarDecorations = ps.avatarDecorations.filter(d => decorationIds.includes(d.id)).map(d => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
|
@ -78,6 +78,8 @@ export const ROLE_POLICIES = [
|
|||||||
'canCreateContent',
|
'canCreateContent',
|
||||||
'canUpdateContent',
|
'canUpdateContent',
|
||||||
'canDeleteContent',
|
'canDeleteContent',
|
||||||
|
'canUpdateAvatar',
|
||||||
|
'canUpdateBanner',
|
||||||
'canInvite',
|
'canInvite',
|
||||||
'inviteLimit',
|
'inviteLimit',
|
||||||
'inviteLimitCycle',
|
'inviteLimitCycle',
|
||||||
|
@ -220,6 +220,46 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</div>
|
</div>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateAvatar, 'canUpdateAvatar'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canUpdateAvatar }}</template>
|
||||||
|
<template #suffix>
|
||||||
|
<span v-if="role.policies.canUpdateAvatar.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||||
|
<span v-else>{{ role.policies.canUpdateAvatar.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||||
|
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateAvatar)"></i></span>
|
||||||
|
</template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkSwitch v-model="role.policies.canUpdateAvatar.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="role.policies.canUpdateAvatar.value" :disabled="role.policies.canUpdateAvatar.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkRange v-model="role.policies.canUpdateAvatar.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||||
|
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||||
|
</MkRange>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBanner, 'canUpdateBanner'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canUpdateBanner }}</template>
|
||||||
|
<template #suffix>
|
||||||
|
<span v-if="role.policies.canUpdateBanner.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
|
||||||
|
<span v-else>{{ role.policies.canUpdateBanner.value ? i18n.ts.yes : i18n.ts.no }}</span>
|
||||||
|
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBanner)"></i></span>
|
||||||
|
</template>
|
||||||
|
<div class="_gaps">
|
||||||
|
<MkSwitch v-model="role.policies.canUpdateBanner.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkSwitch v-model="role.policies.canUpdateBanner.value" :disabled="role.policies.canUpdateBanner.useDefault" :readonly="readonly">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
<MkRange v-model="role.policies.canUpdateBanner.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
|
||||||
|
<template #label>{{ i18n.ts._role.priority }}</template>
|
||||||
|
</MkRange>
|
||||||
|
</div>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
|
||||||
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
|
@ -72,6 +72,22 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
</MkSwitch>
|
</MkSwitch>
|
||||||
</MkFolder>
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateAvatar, 'canUpdateAvatar'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canUpdateAvatar }}</template>
|
||||||
|
<template #suffix>{{ policies.canUpdateAvatar ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||||
|
<MkSwitch v-model="policies.canUpdateAvatar">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBanner, 'canUpdateBanner'])">
|
||||||
|
<template #label>{{ i18n.ts._role._options.canUpdateBanner }}</template>
|
||||||
|
<template #suffix>{{ policies.canUpdateBanner ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||||
|
<MkSwitch v-model="policies.canUpdateBanner">
|
||||||
|
<template #label>{{ i18n.ts.enable }}</template>
|
||||||
|
</MkSwitch>
|
||||||
|
</MkFolder>
|
||||||
|
|
||||||
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
|
<MkFolder v-if="matchQuery([i18n.ts._role._options.canInvite, 'canInvite'])">
|
||||||
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
<template #label>{{ i18n.ts._role._options.canInvite }}</template>
|
||||||
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>
|
<template #suffix>{{ policies.canInvite ? i18n.ts.yes : i18n.ts.no }}</template>
|
||||||
|
Loading…
Reference in New Issue
Block a user