feat: Server rules (#10660)

* enhance(frontend): サーバールールのデザイン調整

* enhance(frontend): i18n

* enhance(frontend): 利用規約URLの設定を「モデレーション」ページへ移動

* enhance(frontend): サーバールールのデザイン調整

* Update CHANGELOG.md

* 不要な差分を削除

* fix(frontend): lint

* ui tweak

* test: add stories

* tweak

* test: bind args

* test: add interaction tests

* fix bug

* Update packages/frontend/src/pages/admin/server-rules.vue

Co-authored-by: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com>

* Update misskey-js.api.md

* chore: windowを明示

* 🎨

* refactor

* 🎨

* 🎨

* fix e2e test

* 🎨

* 🎨

* fix icon

* fix e2e

---------

Co-authored-by: Ebise Lutica <7106976+EbiseLutica@users.noreply.github.com>
Co-authored-by: Acid Chicken (硫酸鶏) <root@acid-chicken.com>
This commit is contained in:
syuilo 2023-04-19 21:24:31 +09:00 committed by GitHub
parent 0f7defc14a
commit e1f9ab77f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 719 additions and 295 deletions

View File

@ -18,6 +18,7 @@
- Node.js 18.6.0以上が必要になりました - Node.js 18.6.0以上が必要になりました
### General ### General
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
- ユーザーへの自分用メモ機能 - ユーザーへの自分用メモ機能
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。 * ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
(自分自身に対してもメモを追加できます。) (自分自身に対してもメモを追加できます。)

View File

@ -52,6 +52,12 @@ describe('After setup instance', () => {
cy.intercept('POST', '/api/signup').as('signup'); cy.intercept('POST', '/api/signup').as('signup');
cy.get('[data-cy-signup]').click(); cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes] [data-cy-folder-header]').click();
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-submit]').should('be.disabled');
cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-submit]').should('be.disabled'); cy.get('[data-cy-signup-submit]').should('be.disabled');
@ -71,6 +77,12 @@ describe('After setup instance', () => {
// ユーザー名が重複している場合の挙動確認 // ユーザー名が重複している場合の挙動確認
cy.get('[data-cy-signup]').click(); cy.get('[data-cy-signup]').click();
cy.get('[data-cy-signup-rules-continue]').should('be.disabled');
cy.get('[data-cy-signup-rules-notes] [data-cy-folder-header]').click();
cy.get('[data-cy-signup-rules-notes-agree] [data-cy-switch-toggle]').click();
cy.get('[data-cy-signup-rules-continue]').should('not.be.disabled');
cy.get('[data-cy-signup-rules-continue]').click();
cy.get('[data-cy-signup-username] input').type('alice'); cy.get('[data-cy-signup-username] input').type('alice');
cy.get('[data-cy-signup-password] input').type('alice1234'); cy.get('[data-cy-signup-password] input').type('alice1234');
cy.get('[data-cy-signup-password-retype] input').type('alice1234'); cy.get('[data-cy-signup-password-retype] input').type('alice1234');

View File

@ -263,9 +263,10 @@ noMoreHistory: "これより過去の履歴はありません"
startMessaging: "チャットを開始" startMessaging: "チャットを開始"
nUsersRead: "{n}人が読みました" nUsersRead: "{n}人が読みました"
agreeTo: "{0}に同意" agreeTo: "{0}に同意"
agree: "同意する"
agreeBelow: "下記に同意する" agreeBelow: "下記に同意する"
basicNotesBeforeCreateAccount: "基本的な注意事項" basicNotesBeforeCreateAccount: "基本的な注意事項"
tos: "利用規約" termsOfService: "利用規約"
start: "始める" start: "始める"
home: "ホーム" home: "ホーム"
remoteUserCaution: "リモートユーザーのため、情報が不完全です。" remoteUserCaution: "リモートユーザーのため、情報が不完全です。"
@ -1010,6 +1011,12 @@ stackAxis: "スタック方向"
vertical: "縦" vertical: "縦"
horizontal: "横" horizontal: "横"
position: "位置" position: "位置"
serverRules: "サーバールール"
pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。"
continue: "続ける"
_serverRules:
description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
_accountMigration: _accountMigration:
moveTo: "このアカウントを新しいアカウントに引っ越す" moveTo: "このアカウントを新しいアカウントに引っ越す"

View File

@ -0,0 +1,11 @@
export class ServerRules1681400427971 {
name = 'ServerRules1681400427971'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "serverRules" character varying(280) array NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "serverRules"`);
}
}

View File

@ -405,4 +405,11 @@ export class Meta {
default: { }, default: { },
}) })
public policies: Record<string, any>; public policies: Record<string, any>;
@Column('varchar', {
length: 280,
array: true,
default: '{}',
})
public serverRules: string[];
} }

View File

@ -94,6 +94,7 @@ export const paramDef = {
enableActiveEmailValidation: { type: 'boolean' }, enableActiveEmailValidation: { type: 'boolean' },
enableChartsForRemoteUser: { type: 'boolean' }, enableChartsForRemoteUser: { type: 'boolean' },
enableChartsForFederatedInstances: { type: 'boolean' }, enableChartsForFederatedInstances: { type: 'boolean' },
serverRules: { type: 'array', items: { type: 'string' } },
}, },
required: [], required: [],
} as const; } as const;
@ -387,6 +388,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances; set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
} }
if (ps.serverRules !== undefined) {
set.serverRules = ps.serverRules;
}
await this.metaService.update(set); await this.metaService.update(set);
this.moderationLogService.insertModerationLog(me, 'updateMeta'); this.moderationLogService.insertModerationLog(me, 'updateMeta');
}); });

View File

@ -310,6 +310,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
translatorAvailable: instance.deeplAuthKey != null, translatorAvailable: instance.deeplAuthKey != null,
serverRules: instance.serverRules,
policies: { ...DEFAULT_POLICIES, ...instance.policies }, policies: { ...DEFAULT_POLICIES, ...instance.policies },
mediaProxy: this.config.mediaProxy, mediaProxy: this.config.mediaProxy,

View File

@ -398,6 +398,7 @@ Promise.all([
glob('src/components/global/*.vue'), glob('src/components/global/*.vue'),
glob('src/components/Mk{A,B}*.vue'), glob('src/components/Mk{A,B}*.vue'),
glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'),
glob('src/components/MkSignupServerRules.vue'),
glob('src/pages/user/home.vue'), glob('src/pages/user/home.vue'),
]) ])
.then((globs) => globs.flat()) .then((globs) => globs.flat())

View File

@ -1,8 +1,8 @@
<template> <template>
<div ref="rootEl" :class="$style.root"> <div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
<MkStickyContainer> <MkStickyContainer>
<template #header> <template #header>
<div :class="[$style.header, { [$style.opened]: opened }]" class="_button" @click="toggle"> <div :class="[$style.header, { [$style.opened]: opened }]" class="_button" role="button" data-cy-folder-header @click="toggle">
<div :class="$style.headerIcon"><slot name="icon"></slot></div> <div :class="$style.headerIcon"><slot name="icon"></slot></div>
<div :class="$style.headerText"> <div :class="$style.headerText">
<div :class="$style.headerTextMain"> <div :class="$style.headerTextMain">
@ -20,7 +20,7 @@
</div> </div>
</template> </template>
<div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }"> <div v-if="openedAtLeastOnce" :class="[$style.body, { [$style.bgSame]: bgSame }]" :style="{ maxHeight: maxHeight ? `${maxHeight}px` : null, overflow: maxHeight ? `auto` : null }" :aria-hidden="!opened">
<Transition <Transition
:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''" :enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''" :leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
@ -196,7 +196,7 @@ onMounted(() => {
.headerRight { .headerRight {
margin-left: auto; margin-left: auto;
opacity: 0.7; color: var(--fgTransparentWeak);
white-space: nowrap; white-space: nowrap;
} }

View File

@ -404,16 +404,10 @@ defineExpose({
right: 0; right: 0;
margin: auto; margin: auto;
padding: 32px; padding: 32px;
// TODO: mask-imageiOS
-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 32px, rgba(0,0,0,1) calc(100% - 32px), rgba(0,0,0,0) 100%);
overflow: auto;
display: flex; display: flex;
@media (max-width: 500px) { @media (max-width: 500px) {
padding: 16px; padding: 16px;
-webkit-mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
mask-image: linear-gradient(0deg, rgba(0,0,0,0) 0%, rgba(0,0,0,1) 16px, rgba(0,0,0,1) calc(100% - 16px), rgba(0,0,0,0) 100%);
} }
} }
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> <MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')">
<div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> <div ref="rootEl" class="ebkgoccj" :style="{ width: `${width}px`, height: height ? `${height}px` : null }" @keydown="onKeydown">
<div ref="headerEl" class="header"> <div ref="headerEl" class="header">
<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
<span class="title"> <span class="title">
@ -25,13 +25,11 @@ const props = withDefaults(defineProps<{
okButtonDisabled: boolean; okButtonDisabled: boolean;
width: number; width: number;
height: number | null; height: number | null;
scroll: boolean;
}>(), { }>(), {
withOkButton: false, withOkButton: false,
okButtonDisabled: false, okButtonDisabled: false,
width: 400, width: 400,
height: null, height: null,
scroll: true,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@ -86,6 +84,7 @@ defineExpose({
<style lang="scss" scoped> <style lang="scss" scoped>
.ebkgoccj { .ebkgoccj {
margin: auto; margin: auto;
max-height: 100%;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,263 +0,0 @@
<template>
<form class="qlvuhzng _gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
<template #label>{{ i18n.ts.invitationCode }}</template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
<div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
</template>
</MkInput>
<MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
<template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-question-circle"></i></div></template>
<template #prefix><i class="ti ti-mail"></i></template>
<template #caption>
<span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
<span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
<span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
<span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
<span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
</template>
</MkInput>
<MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
</template>
</MkInput>
<MkSwitch v-model="ToSAgreement" class="tou">
<template #label>{{ i18n.ts.agreeBelow }}</template>
</MkSwitch>
<ul style="margin: 0; padding-left: 2em;">
<li v-if="instance.tosUrl"><a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.tos }}</a></li>
<li><a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }}</a></li>
</ul>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" class="captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" class="captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" class="captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkButton type="submit" :disabled="shouldDisableSubmitting" gradate data-cy-signup-submit>{{ i18n.ts.start }}</MkButton>
</form>
</template>
<script lang="ts" setup>
import { } from 'vue';
import getPasswordStrength from 'syuilo-password-strength';
import { toUnicode } from 'punycode/';
import MkButton from './MkButton.vue';
import MkInput from './MkInput.vue';
import MkSwitch from './MkSwitch.vue';
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
import * as config from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
autoSet?: boolean;
}>(), {
autoSet: false,
});
const emit = defineEmits<{
(ev: 'signup', user: Record<string, any>): void;
(ev: 'signupEmailPending'): void;
}>();
const host = toUnicode(config.host);
let hcaptcha = $ref<Captcha | undefined>();
let recaptcha = $ref<Captcha | undefined>();
let turnstile = $ref<Captcha | undefined>();
let username: string = $ref('');
let password: string = $ref('');
let retypedPassword: string = $ref('');
let invitationCode: string = $ref('');
let email = $ref('');
let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
let submitting: boolean = $ref(false);
let ToSAgreement: boolean = $ref(false);
let hCaptchaResponse = $ref(null);
let reCaptchaResponse = $ref(null);
let turnstileResponse = $ref(null);
let usernameAbortController: null | AbortController = $ref(null);
let emailAbortController: null | AbortController = $ref(null);
const shouldDisableSubmitting = $computed((): boolean => {
return submitting ||
instance.tosUrl && !ToSAgreement ||
instance.enableHcaptcha && !hCaptchaResponse ||
instance.enableRecaptcha && !reCaptchaResponse ||
instance.enableTurnstile && !turnstileResponse ||
instance.emailRequiredForSignup && emailState !== 'ok' ||
usernameState !== 'ok' ||
passwordRetypeState !== 'match';
});
function onChangeUsername(): void {
if (username === '') {
usernameState = null;
return;
}
{
const err =
!username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
username.length < 1 ? 'min-range' :
username.length > 20 ? 'max-range' :
null;
if (err) {
usernameState = err;
return;
}
}
if (usernameAbortController != null) {
usernameAbortController.abort();
}
usernameState = 'wait';
usernameAbortController = new AbortController();
os.api('username/available', {
username,
}, undefined, usernameAbortController.signal).then(result => {
usernameState = result.available ? 'ok' : 'unavailable';
}).catch((err) => {
if (err.name !== 'AbortError') {
usernameState = 'error';
}
});
}
function onChangeEmail(): void {
if (email === '') {
emailState = null;
return;
}
if (emailAbortController != null) {
emailAbortController.abort();
}
emailState = 'wait';
emailAbortController = new AbortController();
os.api('email-address/available', {
emailAddress: email,
}, undefined, emailAbortController.signal).then(result => {
emailState = result.available ? 'ok' :
result.reason === 'used' ? 'unavailable:used' :
result.reason === 'format' ? 'unavailable:format' :
result.reason === 'disposable' ? 'unavailable:disposable' :
result.reason === 'mx' ? 'unavailable:mx' :
result.reason === 'smtp' ? 'unavailable:smtp' :
'unavailable';
}).catch((err) => {
if (err.name !== 'AbortError') {
emailState = 'error';
}
});
}
function onChangePassword(): void {
if (password === '') {
passwordStrength = '';
return;
}
const strength = getPasswordStrength(password);
passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
}
function onChangePasswordRetype(): void {
if (retypedPassword === '') {
passwordRetypeState = null;
return;
}
passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
}
async function onSubmit(): Promise<void> {
if (submitting) return;
submitting = true;
try {
await os.api('signup', {
username,
password,
emailAddress: email,
invitationCode,
'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse,
'turnstile-response': turnstileResponse,
});
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.t('_signup.emailSent', { email }),
});
emit('signupEmailPending');
} else {
const res = await os.api('signin', {
username,
password,
});
emit('signup', res);
if (props.autoSet) {
return login(res.i);
}
}
} catch {
submitting = false;
hcaptcha?.reset?.();
recaptcha?.reset?.();
turnstile?.reset?.();
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
}
</script>
<style lang="scss" scoped>
.qlvuhzng {
.captcha {
margin: 16px 0;
}
}
</style>

View File

@ -0,0 +1,272 @@
<template>
<div>
<div :class="$style.banner">
<i class="ti ti-user-edit"></i>
</div>
<MkSpacer :margin-min="20" :margin-max="32">
<form class="_gaps_m" autocomplete="new-password" @submit.prevent="onSubmit">
<MkInput v-if="instance.disableRegistration" v-model="invitationCode" type="text" :spellcheck="false" required>
<template #label>{{ i18n.ts.invitationCode }}</template>
<template #prefix><i class="ti ti-key"></i></template>
</MkInput>
<MkInput v-model="username" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:model-value="onChangeUsername">
<template #label>{{ i18n.ts.username }} <div v-tooltip:dialog="i18n.ts.usernameInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix>@</template>
<template #suffix>@{{ host }}</template>
<template #caption>
<div><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.cannotBeChangedLater }}</div>
<span v-if="usernameState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="usernameState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="usernameState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
<span v-else-if="usernameState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
<span v-else-if="usernameState === 'invalid-format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.usernameInvalidFormat }}</span>
<span v-else-if="usernameState === 'min-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooShort }}</span>
<span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.tooLong }}</span>
</template>
</MkInput>
<MkInput v-if="instance.emailRequiredForSignup" v-model="email" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:model-value="onChangeEmail">
<template #label>{{ i18n.ts.emailAddress }} <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix><i class="ti ti-mail"></i></template>
<template #caption>
<span v-if="emailState === 'wait'" style="color:#999"><MkLoading :em="true"/> {{ i18n.ts.checking }}</span>
<span v-else-if="emailState === 'ok'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.available }}</span>
<span v-else-if="emailState === 'unavailable:used'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.used }}</span>
<span v-else-if="emailState === 'unavailable:format'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.format }}</span>
<span v-else-if="emailState === 'unavailable:disposable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.disposable }}</span>
<span v-else-if="emailState === 'unavailable:mx'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.mx }}</span>
<span v-else-if="emailState === 'unavailable:smtp'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts._emailUnavailable.smtp }}</span>
<span v-else-if="emailState === 'unavailable'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.unavailable }}</span>
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span>
</template>
</MkInput>
<MkInput v-model="password" type="password" autocomplete="new-password" required data-cy-signup-password @update:model-value="onChangePassword">
<template #label>{{ i18n.ts.password }}</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordStrength == 'low'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.weakPassword }}</span>
<span v-if="passwordStrength == 'medium'" style="color: var(--warn)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.normalPassword }}</span>
<span v-if="passwordStrength == 'high'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.strongPassword }}</span>
</template>
</MkInput>
<MkInput v-model="retypedPassword" type="password" autocomplete="new-password" required data-cy-signup-password-retype @update:model-value="onChangePasswordRetype">
<template #label>{{ i18n.ts.password }} ({{ i18n.ts.retype }})</template>
<template #prefix><i class="ti ti-lock"></i></template>
<template #caption>
<span v-if="passwordRetypeState == 'match'" style="color: var(--success)"><i class="ti ti-check ti-fw"></i> {{ i18n.ts.passwordMatched }}</span>
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
</template>
</MkInput>
<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
<MkButton type="submit" :disabled="shouldDisableSubmitting" large gradate rounded data-cy-signup-submit style="margin: 0 auto;">
<template v-if="submitting">
<MkLoading :em="true" :colored="false"/>
</template>
<template v-else>{{ i18n.ts.start }}</template>
</MkButton>
</form>
</MkSpacer>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import getPasswordStrength from 'syuilo-password-strength';
import { toUnicode } from 'punycode/';
import MkButton from './MkButton.vue';
import MkInput from './MkInput.vue';
import MkSwitch from './MkSwitch.vue';
import MkCaptcha, { type Captcha } from '@/components/MkCaptcha.vue';
import * as config from '@/config';
import * as os from '@/os';
import { login } from '@/account';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
const props = withDefaults(defineProps<{
autoSet?: boolean;
}>(), {
autoSet: false,
});
const emit = defineEmits<{
(ev: 'signup', user: Record<string, any>): void;
(ev: 'signupEmailPending'): void;
}>();
const host = toUnicode(config.host);
let hcaptcha = $ref<Captcha | undefined>();
let recaptcha = $ref<Captcha | undefined>();
let turnstile = $ref<Captcha | undefined>();
let username: string = $ref('');
let password: string = $ref('');
let retypedPassword: string = $ref('');
let invitationCode: string = $ref('');
let email = $ref('');
let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
let passwordStrength: '' | 'low' | 'medium' | 'high' = $ref('');
let passwordRetypeState: null | 'match' | 'not-match' = $ref(null);
let submitting: boolean = $ref(false);
let hCaptchaResponse = $ref(null);
let reCaptchaResponse = $ref(null);
let turnstileResponse = $ref(null);
let usernameAbortController: null | AbortController = $ref(null);
let emailAbortController: null | AbortController = $ref(null);
const shouldDisableSubmitting = $computed((): boolean => {
return submitting ||
instance.enableHcaptcha && !hCaptchaResponse ||
instance.enableRecaptcha && !reCaptchaResponse ||
instance.enableTurnstile && !turnstileResponse ||
instance.emailRequiredForSignup && emailState !== 'ok' ||
usernameState !== 'ok' ||
passwordRetypeState !== 'match';
});
function onChangeUsername(): void {
if (username === '') {
usernameState = null;
return;
}
{
const err =
!username.match(/^[a-zA-Z0-9_]+$/) ? 'invalid-format' :
username.length < 1 ? 'min-range' :
username.length > 20 ? 'max-range' :
null;
if (err) {
usernameState = err;
return;
}
}
if (usernameAbortController != null) {
usernameAbortController.abort();
}
usernameState = 'wait';
usernameAbortController = new AbortController();
os.api('username/available', {
username,
}, undefined, usernameAbortController.signal).then(result => {
usernameState = result.available ? 'ok' : 'unavailable';
}).catch((err) => {
if (err.name !== 'AbortError') {
usernameState = 'error';
}
});
}
function onChangeEmail(): void {
if (email === '') {
emailState = null;
return;
}
if (emailAbortController != null) {
emailAbortController.abort();
}
emailState = 'wait';
emailAbortController = new AbortController();
os.api('email-address/available', {
emailAddress: email,
}, undefined, emailAbortController.signal).then(result => {
emailState = result.available ? 'ok' :
result.reason === 'used' ? 'unavailable:used' :
result.reason === 'format' ? 'unavailable:format' :
result.reason === 'disposable' ? 'unavailable:disposable' :
result.reason === 'mx' ? 'unavailable:mx' :
result.reason === 'smtp' ? 'unavailable:smtp' :
'unavailable';
}).catch((err) => {
if (err.name !== 'AbortError') {
emailState = 'error';
}
});
}
function onChangePassword(): void {
if (password === '') {
passwordStrength = '';
return;
}
const strength = getPasswordStrength(password);
passwordStrength = strength > 0.7 ? 'high' : strength > 0.3 ? 'medium' : 'low';
}
function onChangePasswordRetype(): void {
if (retypedPassword === '') {
passwordRetypeState = null;
return;
}
passwordRetypeState = password === retypedPassword ? 'match' : 'not-match';
}
async function onSubmit(): Promise<void> {
if (submitting) return;
submitting = true;
try {
await os.api('signup', {
username,
password,
emailAddress: email,
invitationCode,
'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse,
'turnstile-response': turnstileResponse,
});
if (instance.emailRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.t('_signup.emailSent', { email }),
});
emit('signupEmailPending');
} else {
const res = await os.api('signin', {
username,
password,
});
emit('signup', res);
if (props.autoSet) {
return login(res.i);
}
}
} catch {
submitting = false;
hcaptcha?.reset?.();
recaptcha?.reset?.();
turnstile?.reset?.();
os.alert({
type: 'error',
text: i18n.ts.somethingHappened,
});
}
}
</script>
<style lang="scss" module>
.banner {
padding: 16px;
text-align: center;
font-size: 26px;
background-color: var(--accentedBg);
color: var(--accent);
}
.captcha {
margin: 16px 0;
}
</style>

View File

@ -0,0 +1,94 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { StoryObj } from '@storybook/vue3';
import { onBeforeUnmount } from 'vue';
import MkSignupServerRules from './MkSignupDialog,rules.vue';
import { i18n } from '@/i18n';
import { instance } from '@/instance';
export const Empty = {
render(args) {
return {
components: {
MkSignupServerRules,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkSignupServerRules v-bind="props" />',
};
},
async play({ canvasElement }) {
const canvas = within(canvasElement);
const groups = await canvas.findAllByRole('group');
const buttons = await canvas.findAllByRole('button');
for (const group of groups) {
if (group.ariaExpanded === 'true') {
continue;
}
const button = await within(group).findByRole('button');
userEvent.click(button);
await waitFor(() => expect(group).toHaveAttribute('aria-expanded', 'true'));
}
const labels = await canvas.findAllByText(i18n.ts.agree);
for (const label of labels) {
expect(buttons.at(-1)).toBeDisabled();
await waitFor(() => userEvent.click(label));
}
expect(buttons.at(-1)).toBeEnabled();
},
args: {
serverRules: [],
tosUrl: null,
},
decorators: [
(_, context) => ({
setup() {
instance.serverRules = context.args.serverRules;
instance.tosUrl = context.args.tosUrl;
onBeforeUnmount(() => {
// FIXME: 呼び出されない
instance.serverRules = [];
instance.tosUrl = null;
});
},
template: '<story/>',
}),
],
parameters: {
layout: 'centered',
},
} satisfies StoryObj<typeof MkSignupServerRules>;
export const ServerRulesOnly = {
...Empty,
args: {
...Empty.args,
serverRules: [
'ルール',
],
},
} satisfies StoryObj<typeof MkSignupServerRules>;
export const TOSOnly = {
...Empty,
args: {
...Empty.args,
tosUrl: 'https://example.com/tos',
},
} satisfies StoryObj<typeof MkSignupServerRules>;
export const ServerRulesAndTOS = {
...Empty,
args: {
...Empty.args,
serverRules: ServerRulesOnly.args.serverRules,
tosUrl: TOSOnly.args.tosUrl,
},
} satisfies StoryObj<typeof MkSignupServerRules>;

View File

@ -0,0 +1,114 @@
<template>
<div>
<div :class="$style.banner">
<i class="ti ti-checklist"></i>
</div>
<MkSpacer :margin-min="20" :margin-max="28">
<div class="_gaps_m">
<div style="text-align: center;">{{ i18n.ts.pleaseConfirmBelowBeforeSignup }}</div>
<MkFolder v-if="availableServerRules" :default-open="true">
<template #label>{{ i18n.ts.serverRules }}</template>
<template #suffix><i v-if="agreeServerRules" class="ti ti-check" style="color: var(--success)"></i></template>
<ol class="_gaps_s" :class="$style.rules">
<li v-for="item in instance.serverRules" :class="$style.rule"><div :class="$style.ruleText" v-html="item"></div></li>
</ol>
<MkSwitch v-model="agreeServerRules" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<MkFolder v-if="availableTos">
<template #label>{{ i18n.ts.termsOfService }}</template>
<template #suffix><i v-if="agreeTos" class="ti ti-check" style="color: var(--success)"></i></template>
<a :href="instance.tosUrl" class="_link" target="_blank">{{ i18n.ts.termsOfService }} <i class="ti ti-external-link"></i></a>
<MkSwitch v-model="agreeTos" style="margin-top: 16px;">{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<MkFolder data-cy-signup-rules-notes>
<template #label>{{ i18n.ts.basicNotesBeforeCreateAccount }}</template>
<template #suffix><i v-if="agreeNote" class="ti ti-check" style="color: var(--success)"></i></template>
<a href="https://misskey-hub.net/docs/notes.html" class="_link" target="_blank">{{ i18n.ts.basicNotesBeforeCreateAccount }} <i class="ti ti-external-link"></i></a>
<MkSwitch v-model="agreeNote" style="margin-top: 16px;" data-cy-signup-rules-notes-agree>{{ i18n.ts.agree }}</MkSwitch>
</MkFolder>
<MkButton primary rounded gradate style="margin: 0 auto;" :disabled="!agreed" data-cy-signup-rules-continue @click="emit('accept')">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton>
</div>
</MkSpacer>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { instance } from '@/instance';
import { i18n } from '@/i18n';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue';
const availableServerRules = instance.serverRules.length > 0;
const availableTos = instance.tosUrl != null;
const agreeServerRules = ref(false);
const agreeTos = ref(false);
const agreeNote = ref(false);
const agreed = computed(() => {
return (!availableServerRules || agreeServerRules.value) && (!availableTos || agreeTos.value) && agreeNote.value;
});
const emit = defineEmits<{
(ev: 'accept'): void;
}>();
</script>
<style lang="scss" module>
.banner {
padding: 16px;
text-align: center;
font-size: 26px;
background-color: var(--accentedBg);
color: var(--accent);
}
.rules {
counter-reset: item;
list-style: none;
padding: 0;
margin: 0;
}
.rule {
display: flex;
align-items: center;
gap: 8px;
word-break: break-word;
&::before {
flex-shrink: 0;
display: flex;
position: sticky;
top: calc(var(--stickyTop, 0px) + 8px);
counter-increment: item;
content: counter(item);
width: 32px;
height: 32px;
line-height: 32px;
background-color: var(--accentedBg);
color: var(--accent);
font-size: 13px;
font-weight: bold;
align-items: center;
justify-content: center;
border-radius: 999px;
}
}
.ruleText {
padding-top: 6px;
}
</style>

View File

@ -1,24 +1,40 @@
<template> <template>
<MkModalWindow <MkModalWindow
ref="dialog" ref="dialog"
:width="366" :width="500"
:height="500" :height="600"
@close="dialog.close()" @close="dialog.close()"
@closed="$emit('closed')" @closed="$emit('closed')"
> >
<template #header>{{ i18n.ts.signup }}</template> <template #header>{{ i18n.ts.signup }}</template>
<MkSpacer :margin-min="20" :margin-max="28"> <div style="overflow-x: clip;">
<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/> <Transition
</MkSpacer> mode="out-in"
:enter-active-class="$style.transition_x_enterActive"
:leave-active-class="$style.transition_x_leaveActive"
:enter-from-class="$style.transition_x_enterFrom"
:leave-to-class="$style.transition_x_leaveTo"
>
<template v-if="!isAcceptedServerRule">
<XServerRules @accept="isAcceptedServerRule = true"/>
</template>
<template v-else>
<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
</template>
</Transition>
</div>
</MkModalWindow> </MkModalWindow>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { } from 'vue'; import { } from 'vue';
import XSignup from '@/components/MkSignup.vue'; import { $ref } from 'vue/macros';
import XSignup from '@/components/MkSignupDialog.form.vue';
import XServerRules from '@/components/MkSignupDialog.rules.vue';
import MkModalWindow from '@/components/MkModalWindow.vue'; import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { instance } from '@/instance';
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
autoSet?: boolean; autoSet?: boolean;
@ -33,6 +49,8 @@ const emit = defineEmits<{
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>(); const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
const isAcceptedServerRule = $ref(false);
function onSignup(res) { function onSignup(res) {
emit('done', res); emit('done', res);
dialog.close(); dialog.close();
@ -42,3 +60,18 @@ function onSignupEmailPending() {
dialog.close(); dialog.close();
} }
</script> </script>
<style lang="scss" module>
.transition_x_enterActive,
.transition_x_leaveActive {
transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
}
.transition_x_enterFrom {
opacity: 0;
transform: translateX(50px);
}
.transition_x_leaveTo {
opacity: 0;
transform: translateX(-50px);
}
</style>

View File

@ -9,7 +9,7 @@
:disabled="disabled" :disabled="disabled"
@keydown.enter="toggle" @keydown.enter="toggle"
> >
<span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" @click.prevent="toggle"> <span ref="button" v-tooltip="checked ? i18n.ts.itsOn : i18n.ts.itsOff" class="button" data-cy-switch-toggle @click.prevent="toggle">
<div class="knob"></div> <div class="knob"></div>
</span> </span>
<span class="label"> <span class="label">

View File

@ -41,7 +41,7 @@
<template #value>{{ instance.maintainerEmail }}</template> <template #value>{{ instance.maintainerEmail }}</template>
</MkKeyValue> </MkKeyValue>
</FormSplit> </FormSplit>
<FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.tos }}</FormLink> <FormLink v-if="instance.tosUrl" :to="instance.tosUrl" external>{{ i18n.ts.termsOfService }}</FormLink>
</div> </div>
</FormSection> </FormSection>

View File

@ -3,10 +3,15 @@
<MkStickyContainer> <MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template> <template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> <MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<FormSuspense :p="init"> <FormSuspense :p="init">
<div class="_gaps_m"> <div class="_gaps_m">
<FormSection first> <FormSection first>
<div class="_gaps_m"> <div class="_gaps_m">
<MkInput v-model="tosUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput>
<MkTextarea v-model="sensitiveWords"> <MkTextarea v-model="sensitiveWords">
<template #label>{{ i18n.ts.sensitiveWords }}</template> <template #label>{{ i18n.ts.sensitiveWords }}</template>
<template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template> <template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template>
@ -41,16 +46,20 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata'; import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue'; import MkButton from '@/components/MkButton.vue';
import FormLink from "@/components/form/link.vue";
let sensitiveWords: string = $ref(''); let sensitiveWords: string = $ref('');
let tosUrl: string | null = $ref(null);
async function init() { async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
sensitiveWords = meta.sensitiveWords.join('\n'); sensitiveWords = meta.sensitiveWords.join('\n');
tosUrl = meta.tosUrl;
} }
function save() { function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
tosUrl,
sensitiveWords: sensitiveWords.split('\n'), sensitiveWords: sensitiveWords.split('\n'),
}).then(() => { }).then(() => {
fetchInstance(); fetchInstance();

View File

@ -0,0 +1,128 @@
<template>
<div>
<MkStickyContainer>
<template #header><XHeader :tabs="headerTabs"/></template>
<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
<div class="_gaps_m">
<div>{{ i18n.ts._serverRules.description }}</div>
<Sortable
v-model="serverRules"
class="_gaps_m"
:item-key="(_, i) => i"
:animation="150"
:handle="'.' + $style.itemHandle"
@start="e => e.item.classList.add('active')"
@end="e => e.item.classList.remove('active')"
>
<template #item="{element,index}">
<div :class="$style.item">
<div :class="$style.itemHeader">
<div :class="$style.itemNumber" v-text="String(index + 1)"/>
<span :class="$style.itemHandle"><i class="ti ti-menu"/></span>
<button class="_button" :class="$style.itemRemove" @click="remove(index)"><i class="ti ti-x"></i></button>
</div>
<MkInput v-model="serverRules[index]"/>
</div>
</template>
</Sortable>
<div :class="$style.commands">
<MkButton rounded @click="serverRules.push('')"><i class="ti ti-plus"></i> {{ i18n.ts.add }}</MkButton>
<MkButton primary rounded :class="$style.buttonSave" @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent } from 'vue';
import XHeader from './_header_.vue';
import * as os from '@/os';
import { fetchInstance, instance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
let serverRules: string[] = $ref(instance.serverRules);
const save = async () => {
await os.apiWithDialog('admin/update-meta', {
serverRules,
});
fetchInstance();
};
const remove = (index: number): void => {
serverRules.splice(index, 1);
};
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.serverRules,
icon: 'ti ti-checkbox',
});
</script>
<style lang="scss" module>
.item {
display: block;
color: var(--navFg);
}
.itemHeader {
display: flex;
margin-bottom: 8px;
align-items: center;
}
.itemHandle {
display: flex;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
cursor: move;
}
.itemNumber {
display: flex;
background-color: var(--accentedBg);
color: var(--accent);
font-size: 14px;
font-weight: bold;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 999px;
margin-right: 8px;
}
.itemEdit {
width: 100%;
max-width: 100%;
min-width: 100%;
}
.itemRemove {
width: 40px;
height: 40px;
color: var(--error);
margin-left: auto;
border-radius: 6px;
&:hover {
background: var(--X5);
}
}
.commands {
display: flex;
gap: 16px;
}
</style>

View File

@ -13,11 +13,6 @@
<template #label>{{ i18n.ts.instanceDescription }}</template> <template #label>{{ i18n.ts.instanceDescription }}</template>
</MkTextarea> </MkTextarea>
<MkInput v-model="tosUrl">
<template #prefix><i class="ti ti-link"></i></template>
<template #label>{{ i18n.ts.tosUrl }}</template>
</MkInput>
<FormSplit :min-width="300"> <FormSplit :min-width="300">
<MkInput v-model="maintainerName"> <MkInput v-model="maintainerName">
<template #label>{{ i18n.ts.maintainerName }}</template> <template #label>{{ i18n.ts.maintainerName }}</template>
@ -169,7 +164,6 @@ import MkButton from '@/components/MkButton.vue';
let name: string | null = $ref(null); let name: string | null = $ref(null);
let description: string | null = $ref(null); let description: string | null = $ref(null);
let tosUrl: string | null = $ref(null);
let maintainerName: string | null = $ref(null); let maintainerName: string | null = $ref(null);
let maintainerEmail: string | null = $ref(null); let maintainerEmail: string | null = $ref(null);
let iconUrl: string | null = $ref(null); let iconUrl: string | null = $ref(null);
@ -194,7 +188,6 @@ async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
name = meta.name; name = meta.name;
description = meta.description; description = meta.description;
tosUrl = meta.tosUrl;
iconUrl = meta.iconUrl; iconUrl = meta.iconUrl;
bannerUrl = meta.bannerUrl; bannerUrl = meta.bannerUrl;
backgroundImageUrl = meta.backgroundImageUrl; backgroundImageUrl = meta.backgroundImageUrl;
@ -220,7 +213,6 @@ function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
name, name,
description, description,
tosUrl,
iconUrl, iconUrl,
bannerUrl, bannerUrl,
backgroundImageUrl, backgroundImageUrl,

View File

@ -427,6 +427,10 @@ export const routes = [{
path: '/other-settings', path: '/other-settings',
name: 'other-settings', name: 'other-settings',
component: page(() => import('./pages/admin/other-settings.vue')), component: page(() => import('./pages/admin/other-settings.vue')),
}, {
path: '/server-rules',
name: 'server-rules',
component: page(() => import('./pages/admin/server-rules.vue')),
}, { }, {
path: '/', path: '/',
component: page(() => import('./pages/_empty_.vue')), component: page(() => import('./pages/_empty_.vue')),

View File

@ -164,7 +164,7 @@ export const defaultStore = markRaw(new Storage('base', {
}, },
animation: { animation: {
where: 'device', where: 'device',
default: !matchMedia('(prefers-reduced-motion)').matches, default: !window.matchMedia('(prefers-reduced-motion)').matches,
}, },
animatedMfm: { animatedMfm: {
where: 'device', where: 'device',
@ -188,7 +188,7 @@ export const defaultStore = markRaw(new Storage('base', {
}, },
disableShowingAnimatedImages: { disableShowingAnimatedImages: {
where: 'device', where: 'device',
default: matchMedia('(prefers-reduced-motion)').matches, default: window.matchMedia('(prefers-reduced-motion)').matches,
}, },
emojiStyle: { emojiStyle: {
where: 'device', where: 'device',

View File

@ -2348,6 +2348,7 @@ type LiteInstanceMetadata = {
imageUrl: string; imageUrl: string;
}[]; }[];
translatorAvailable: boolean; translatorAvailable: boolean;
serverRules: string[];
}; };
// @public (undocumented) // @public (undocumented)

View File

@ -315,6 +315,7 @@ export type LiteInstanceMetadata = {
imageUrl: string; imageUrl: string;
}[]; }[];
translatorAvailable: boolean; translatorAvailable: boolean;
serverRules: string[];
}; };
export type DetailedInstanceMetadata = LiteInstanceMetadata & { export type DetailedInstanceMetadata = LiteInstanceMetadata & {