feat: [frontend / backend / sdk] a bunch of changes
backend: - Validate registrationWord on backend - Add maxRegPerIP rate limit how much accounts can be created per-ip frontend: - Add support for bulk approval rejection / acception - Address unsafe cookies-js defaults - Address addition of maxRegPerIP sdk: - Type definitions
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-FileCopyrightText: pawinput and pawkey project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function login(token: AccountWithToken['token'], redirect?: string)
|
||||
token,
|
||||
}));
|
||||
|
||||
Cookies.set('token', token, { expires: 365 * 100 });
|
||||
Cookies.set('token', token, { expires: 365 * 100, secure: true, sameSite: 'lax' });
|
||||
|
||||
await addAccount(host, me, token);
|
||||
|
||||
|
||||
@@ -328,7 +328,6 @@ export function inputText(props: {
|
||||
} | {
|
||||
canceled: false; result: string;
|
||||
}>;
|
||||
// min lengthが指定されてたら result は null になり得ないことを保証する overload function
|
||||
export function inputText(props: {
|
||||
type?: 'text' | 'email' | 'password' | 'url';
|
||||
title?: string;
|
||||
@@ -566,7 +565,7 @@ export function success(): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
export function waiting(text?: string | null): Promise<void> {
|
||||
export function waiting(text?: string | null): Promise<{ close: () => void }> {
|
||||
return new Promise(resolve => {
|
||||
const showing = ref(true);
|
||||
const { dispose } = popup(MkWaitingDialog, {
|
||||
@@ -574,9 +573,15 @@ export function waiting(text?: string | null): Promise<void> {
|
||||
showing: showing,
|
||||
text,
|
||||
}, {
|
||||
done: () => resolve(),
|
||||
done: () => {},
|
||||
closed: () => dispose(),
|
||||
});
|
||||
|
||||
resolve({
|
||||
close: () => {
|
||||
showing.value = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,59 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: marie and other Sharkey contributors
|
||||
SPDX-FileCopyrightText: pawinput and pawkey project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageWithHeader :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 900px;">
|
||||
<div class="_gaps_m">
|
||||
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
|
||||
<template #default="{ items }">
|
||||
<div class="_gaps_s">
|
||||
<SkApprovalUser v-for="item in items" :key="item.id" :user="(item as any)" :onDeleted="deleted"/>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
<div>
|
||||
<PageWithHeader :tabs="headerTabs">
|
||||
<div class="_spacer" style="--MI_SPACER-w: 900px;">
|
||||
<div class="_gaps_m">
|
||||
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
|
||||
<template #default="{ items }">
|
||||
<div :class="$style.buttons">
|
||||
<MkButton @click="toggleSelectAll">
|
||||
{{ allSelected ? 'Unselect All' : 'Select All' }}
|
||||
</MkButton>
|
||||
<MkButton danger :disabled="selectedIds.size === 0" @click="rejectSelected">
|
||||
Reject Selected ({{ selectedIds.size }})
|
||||
</MkButton>
|
||||
<MkButton accent :disabled="selectedIds.size === 0" @click="approveSelected">
|
||||
Approve Selected ({{ selectedIds.size }})
|
||||
</MkButton>
|
||||
</div>
|
||||
|
||||
<div class="_gaps_s">
|
||||
<div v-for="item in items" :key="item.id" :class="$style.box">
|
||||
<input
|
||||
:class="$style.checkbox"
|
||||
type="checkbox"
|
||||
:checked="selectedIds.has(item.id)"
|
||||
@change="toggleSelection(item.id, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<SkApprovalUser style="width: 100%;" :user="(item as any)" :onDeleted="deleted" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</MkPagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
</div>
|
||||
</PageWithHeader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { computed, ref, useTemplateRef } from 'vue';
|
||||
import MkPagination from '@/components/MkPagination.vue';
|
||||
import SkApprovalUser from '@/components/SkApprovalUser.vue';
|
||||
import MkButton from '@/components/MkButton.vue';
|
||||
import * as os from '@/os.js';
|
||||
import { i18n } from '@/i18n.js';
|
||||
import { definePage } from '@/page';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
|
||||
let paginationComponent = useTemplateRef<InstanceType<typeof MkPagination>>('paginationComponent');
|
||||
const selectedIds = ref<Set<string>>(new Set());
|
||||
const allSelected = ref(false);
|
||||
|
||||
const pagination = {
|
||||
endpoint: 'admin/show-users' as const,
|
||||
@@ -41,7 +66,129 @@ const pagination = {
|
||||
offsetMode: true,
|
||||
};
|
||||
|
||||
function toggleSelection(id: string, checked: boolean) {
|
||||
if (checked) {
|
||||
selectedIds.value.add(id);
|
||||
} else {
|
||||
selectedIds.value.delete(id);
|
||||
}
|
||||
// Trigger reactivity
|
||||
selectedIds.value = new Set(selectedIds.value);
|
||||
}
|
||||
|
||||
async function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
selectedIds.value.clear();
|
||||
allSelected.value = false;
|
||||
} else {
|
||||
const { close } = await os.waiting('Loading all users...');
|
||||
|
||||
try {
|
||||
const allUsers: any[] = [];
|
||||
let offset = 0;
|
||||
const limit = 100;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const users: any[] = await misskeyApi('admin/show-users', {
|
||||
...pagination.params.value,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
allUsers.push(...users);
|
||||
offset += users.length;
|
||||
|
||||
if (users.length < limit) {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
selectedIds.value = new Set(allUsers.map((u: any) => u.id));
|
||||
allSelected.value = true;
|
||||
|
||||
close();
|
||||
await os.success();
|
||||
} catch (error) {
|
||||
close();
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: 'Failed to load all users',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectSelected() {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
title: 'Reject Selected Users',
|
||||
text: `Are you sure you want to reject ${selectedIds.value.size} selected user(s)?`,
|
||||
});
|
||||
|
||||
if (confirm.canceled) return;
|
||||
|
||||
const { close } = await os.waiting(`Rejecting ${selectedIds.value.size} user(s)...`);
|
||||
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await misskeyApi('admin/decline-user', { userId: id });
|
||||
deleted(id);
|
||||
}
|
||||
|
||||
selectedIds.value.clear();
|
||||
close();
|
||||
await os.success();
|
||||
} catch (error) {
|
||||
close();
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: 'Failed to reject users',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function approveSelected() {
|
||||
const confirm = await os.confirm({
|
||||
type: 'warning',
|
||||
title: 'Approve Selected Users',
|
||||
text: `Are you sure you want to approve ${selectedIds.value.size} selected user(s)?`,
|
||||
});
|
||||
|
||||
if (confirm.canceled) return;
|
||||
|
||||
const { close } = await os.waiting(`Approving ${selectedIds.value.size} user(s)...`);
|
||||
|
||||
try {
|
||||
for (const id of selectedIds.value) {
|
||||
await misskeyApi('admin/approve-user', { userId: id });
|
||||
deleted(id);
|
||||
}
|
||||
|
||||
selectedIds.value.clear();
|
||||
close();
|
||||
await os.success();
|
||||
} catch (error) {
|
||||
close();
|
||||
os.alert({
|
||||
type: 'error',
|
||||
text: 'Failed to approve users',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleted(id: string) {
|
||||
selectedIds.value.delete(id);
|
||||
selectedIds.value = new Set(selectedIds.value);
|
||||
|
||||
if (allSelected.value && selectedIds.value.size === 0) {
|
||||
allSelected.value = false;
|
||||
}
|
||||
|
||||
if (paginationComponent.value) {
|
||||
paginationComponent.value.items.delete(id);
|
||||
}
|
||||
@@ -67,4 +214,24 @@ definePage(() => ({
|
||||
.input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.box {
|
||||
background-color: var(--MI_THEME-panel);
|
||||
padding: 10px;
|
||||
border-radius: var(--MI-radius);
|
||||
display: flex;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
gap: 10px;
|
||||
|
||||
.checkbox {
|
||||
margin-top: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
SPDX-FileCopyrightText: pawinput and pawkey project
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
|
||||
@@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<MkFolder v-if="meta.federation !== 'none'">
|
||||
<template #label>{{ i18n.ts.authorizedFetchSection }}</template>
|
||||
<template #suffix>{{ meta.allowUnsignedFetch !== 'always' ? i18n.ts.enabled : i18n.ts.disabled
|
||||
}}</template>
|
||||
}}</template>
|
||||
<template v-if="authFetchForm.modified.value" #footer>
|
||||
<MkFormFooter :form="authFetchForm" />
|
||||
</template>
|
||||
@@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #caption>{{ i18n.ts.authorizedFetchDescription }}</template>
|
||||
<option value="never">{{ i18n.ts._authorizedFetchValue.never }} - {{
|
||||
i18n.ts._authorizedFetchValueDescription.never
|
||||
}}</option>
|
||||
}}</option>
|
||||
<option value="always">{{ i18n.ts._authorizedFetchValue.always }} - {{
|
||||
i18n.ts._authorizedFetchValueDescription.always }}</option>
|
||||
<option value="essential">{{ i18n.ts._authorizedFetchValue.essential }} - {{
|
||||
@@ -94,11 +94,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</MkFolder>
|
||||
|
||||
<MkFolder>
|
||||
<template #label>Registration Word</template>
|
||||
<template v-if="regWordForm.savedState.regWordRequired" #suffix>Enabled</template>
|
||||
<template v-else #suffix>Disabled</template>
|
||||
<template v-if="regWordForm.modified.value" #footer>
|
||||
<MkFormFooter :form="regWordForm" />
|
||||
<template #label>User Registration</template>
|
||||
<template v-if="regWordForm.modified.value || regMaxIpsForm.modified.value" #footer>
|
||||
<div class="_gaps_s">
|
||||
<MkFormFooter v-if="regWordForm.modified.value" :form="regWordForm" />
|
||||
<MkFormFooter v-if="regMaxIpsForm.modified.value" :form="regMaxIpsForm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="_gaps_m">
|
||||
@@ -110,6 +111,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
<template #prefix><i class="ti ti-key"></i></template>
|
||||
<template #label>Registration Word</template>
|
||||
</MkInput>
|
||||
<MkInput v-model="regMaxIpsForm.state.maxRegPerIp" type="number">
|
||||
<template #label>Max registrations per IP</template>
|
||||
</MkInput>
|
||||
</div>
|
||||
</MkFolder>
|
||||
</div>
|
||||
@@ -118,7 +122,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import XBotProtection from './bot-protection.vue';
|
||||
import MkFolder from '@/components/MkFolder.vue';
|
||||
import MkRadios from '@/components/MkRadios.vue';
|
||||
@@ -165,6 +169,15 @@ const regWordForm = useForm({
|
||||
fetchInstance(true);
|
||||
});
|
||||
|
||||
const regMaxIpsForm = useForm({
|
||||
maxRegPerIp: meta.maxRegPerIp,
|
||||
}, async (state) => {
|
||||
await os.apiWithDialog('admin/update-meta', {
|
||||
maxRegPerIp: Number(state.maxRegPerIp),
|
||||
});
|
||||
fetchInstance(true);
|
||||
})
|
||||
|
||||
const emailValidationForm = useForm({
|
||||
enableActiveEmailValidation: meta.enableActiveEmailValidation,
|
||||
enableVerifymailApi: meta.enableVerifymailApi,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as os from '@/os.js';
|
||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||
import type { Endpoint } from '@/utility/misskey-api.js';
|
||||
|
||||
export async function loadAllPages<E extends Endpoint>(
|
||||
endpoint: E,
|
||||
params: any = {},
|
||||
limit: number = 30
|
||||
): Promise<any[]> {
|
||||
const waitingDispose = await os.waiting('Loading all items...');
|
||||
|
||||
try {
|
||||
const allItems: any[] = [];
|
||||
let untilId: string | undefined = undefined;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const items = await misskeyApi(endpoint, {
|
||||
...params,
|
||||
limit,
|
||||
untilId,
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
allItems.push(...items);
|
||||
untilId = items[items.length - 1].id;
|
||||
|
||||
if (items.length < limit) {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await os.success();
|
||||
return allItems;
|
||||
} catch (error) {
|
||||
await os.alert({
|
||||
type: 'error',
|
||||
text: 'Failed to load all items',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user