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:
parent
0f7defc14a
commit
e1f9ab77f8
@ -18,6 +18,7 @@
|
|||||||
- Node.js 18.6.0以上が必要になりました
|
- Node.js 18.6.0以上が必要になりました
|
||||||
|
|
||||||
### General
|
### General
|
||||||
|
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
|
||||||
- ユーザーへの自分用メモ機能
|
- ユーザーへの自分用メモ機能
|
||||||
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
|
* ユーザーに対して、自分だけが見られるメモを追加できるようになりました。
|
||||||
(自分自身に対してもメモを追加できます。)
|
(自分自身に対してもメモを追加できます。)
|
||||||
|
@ -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');
|
||||||
|
@ -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: "このアカウントを新しいアカウントに引っ越す"
|
||||||
|
11
packages/backend/migration/1681400427971-serverRules.js
Normal file
11
packages/backend/migration/1681400427971-serverRules.js
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
@ -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');
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
@ -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())
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -404,16 +404,10 @@ defineExpose({
|
|||||||
right: 0;
|
right: 0;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
// TODO: mask-imageはiOSだとやたら重い。なんとかしたい
|
|
||||||
-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%);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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>
|
|
272
packages/frontend/src/components/MkSignupDialog.form.vue
Normal file
272
packages/frontend/src/components/MkSignupDialog.form.vue
Normal 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>
|
@ -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>;
|
114
packages/frontend/src/components/MkSignupDialog.rules.vue
Normal file
114
packages/frontend/src/components/MkSignupDialog.rules.vue
Normal 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>
|
@ -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;">
|
||||||
|
<Transition
|
||||||
|
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"/>
|
<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
|
||||||
</MkSpacer>
|
</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>
|
||||||
|
@ -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">
|
||||||
|
@ -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>
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
128
packages/frontend/src/pages/admin/server-rules.vue
Normal file
128
packages/frontend/src/pages/admin/server-rules.vue
Normal 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>
|
@ -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,
|
||||||
|
@ -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')),
|
||||||
|
@ -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',
|
||||||
|
@ -2348,6 +2348,7 @@ type LiteInstanceMetadata = {
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
}[];
|
}[];
|
||||||
translatorAvailable: boolean;
|
translatorAvailable: boolean;
|
||||||
|
serverRules: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
|
@ -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 & {
|
||||||
|
Loading…
Reference in New Issue
Block a user