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
					
				
					 25 changed files with 719 additions and 295 deletions
				
			
		| 
						 | 
				
			
			@ -18,6 +18,7 @@
 | 
			
		|||
- Node.js 18.6.0以上が必要になりました
 | 
			
		||||
 | 
			
		||||
### General
 | 
			
		||||
- 新規登録前に簡潔なルールをユーザーに表示できる、サーバールール機能を追加
 | 
			
		||||
- ユーザーへの自分用メモ機能
 | 
			
		||||
  * ユーザーに対して、自分だけが見られるメモを追加できるようになりました。  
 | 
			
		||||
    (自分自身に対してもメモを追加できます。)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -52,6 +52,12 @@ describe('After setup instance', () => {
 | 
			
		|||
		cy.intercept('POST', '/api/signup').as('signup');
 | 
			
		||||
 | 
			
		||||
		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-username] input').type('alice');
 | 
			
		||||
		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-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-password] input').type('alice1234');
 | 
			
		||||
		cy.get('[data-cy-signup-password-retype] input').type('alice1234');
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -263,9 +263,10 @@ noMoreHistory: "これより過去の履歴はありません"
 | 
			
		|||
startMessaging: "チャットを開始"
 | 
			
		||||
nUsersRead: "{n}人が読みました"
 | 
			
		||||
agreeTo: "{0}に同意"
 | 
			
		||||
agree: "同意する"
 | 
			
		||||
agreeBelow: "下記に同意する"
 | 
			
		||||
basicNotesBeforeCreateAccount: "基本的な注意事項"
 | 
			
		||||
tos: "利用規約"
 | 
			
		||||
termsOfService: "利用規約"
 | 
			
		||||
start: "始める"
 | 
			
		||||
home: "ホーム"
 | 
			
		||||
remoteUserCaution: "リモートユーザーのため、情報が不完全です。"
 | 
			
		||||
| 
						 | 
				
			
			@ -1010,6 +1011,12 @@ stackAxis: "スタック方向"
 | 
			
		|||
vertical: "縦"
 | 
			
		||||
horizontal: "横"
 | 
			
		||||
position: "位置"
 | 
			
		||||
serverRules: "サーバールール"
 | 
			
		||||
pleaseConfirmBelowBeforeSignup: "このサーバーに登録する前に、以下を確認してください。"
 | 
			
		||||
continue: "続ける"
 | 
			
		||||
 | 
			
		||||
_serverRules:
 | 
			
		||||
  description: "新規登録前に表示する、サーバーの簡潔なルールを設定します。内容は利用規約の要約とすることを推奨します。"
 | 
			
		||||
 | 
			
		||||
_accountMigration:
 | 
			
		||||
  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: { },
 | 
			
		||||
	})
 | 
			
		||||
	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' },
 | 
			
		||||
		enableChartsForRemoteUser: { type: 'boolean' },
 | 
			
		||||
		enableChartsForFederatedInstances: { type: 'boolean' },
 | 
			
		||||
		serverRules: { type: 'array', items: { type: 'string' } },
 | 
			
		||||
	},
 | 
			
		||||
	required: [],
 | 
			
		||||
} as const;
 | 
			
		||||
| 
						 | 
				
			
			@ -387,6 +388,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 | 
			
		|||
				set.enableChartsForFederatedInstances = ps.enableChartsForFederatedInstances;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if (ps.serverRules !== undefined) {
 | 
			
		||||
				set.serverRules = ps.serverRules;
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			await this.metaService.update(set);
 | 
			
		||||
			this.moderationLogService.insertModerationLog(me, 'updateMeta');
 | 
			
		||||
		});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -310,6 +310,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 | 
			
		|||
 | 
			
		||||
				translatorAvailable: instance.deeplAuthKey != null,
 | 
			
		||||
 | 
			
		||||
				serverRules: instance.serverRules,
 | 
			
		||||
 | 
			
		||||
				policies: { ...DEFAULT_POLICIES, ...instance.policies },
 | 
			
		||||
 | 
			
		||||
				mediaProxy: this.config.mediaProxy,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -398,6 +398,7 @@ Promise.all([
 | 
			
		|||
	glob('src/components/global/*.vue'),
 | 
			
		||||
	glob('src/components/Mk{A,B}*.vue'),
 | 
			
		||||
	glob('src/components/MkGalleryPostPreview.vue'),
 | 
			
		||||
	glob('src/components/MkSignupServerRules.vue'),
 | 
			
		||||
	glob('src/pages/user/home.vue'),
 | 
			
		||||
])
 | 
			
		||||
	.then((globs) => globs.flat())
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,8 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div ref="rootEl" :class="$style.root">
 | 
			
		||||
<div ref="rootEl" :class="$style.root" role="group" :aria-expanded="opened">
 | 
			
		||||
	<MkStickyContainer>
 | 
			
		||||
		<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.headerText">
 | 
			
		||||
					<div :class="$style.headerTextMain">
 | 
			
		||||
| 
						 | 
				
			
			@ -20,7 +20,7 @@
 | 
			
		|||
			</div>
 | 
			
		||||
		</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
 | 
			
		||||
				:enter-active-class="defaultStore.state.animation ? $style.transition_toggle_enterActive : ''"
 | 
			
		||||
				:leave-active-class="defaultStore.state.animation ? $style.transition_toggle_leaveActive : ''"
 | 
			
		||||
| 
						 | 
				
			
			@ -196,7 +196,7 @@ onMounted(() => {
 | 
			
		|||
 | 
			
		||||
.headerRight {
 | 
			
		||||
	margin-left: auto;
 | 
			
		||||
	opacity: 0.7;
 | 
			
		||||
	color: var(--fgTransparentWeak);
 | 
			
		||||
	white-space: nowrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -404,16 +404,10 @@ defineExpose({
 | 
			
		|||
			right: 0;
 | 
			
		||||
			margin: auto;
 | 
			
		||||
			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;
 | 
			
		||||
 | 
			
		||||
			@media (max-width: 500px) {
 | 
			
		||||
				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>
 | 
			
		||||
<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">
 | 
			
		||||
			<button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="ti ti-x"></i></button>
 | 
			
		||||
			<span class="title">
 | 
			
		||||
| 
						 | 
				
			
			@ -25,13 +25,11 @@ const props = withDefaults(defineProps<{
 | 
			
		|||
	okButtonDisabled: boolean;
 | 
			
		||||
	width: number;
 | 
			
		||||
	height: number | null;
 | 
			
		||||
	scroll: boolean;
 | 
			
		||||
}>(), {
 | 
			
		||||
	withOkButton: false,
 | 
			
		||||
	okButtonDisabled: false,
 | 
			
		||||
	width: 400,
 | 
			
		||||
	height: null,
 | 
			
		||||
	scroll: true,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits<{
 | 
			
		||||
| 
						 | 
				
			
			@ -86,6 +84,7 @@ defineExpose({
 | 
			
		|||
<style lang="scss" scoped>
 | 
			
		||||
.ebkgoccj {
 | 
			
		||||
	margin: auto;
 | 
			
		||||
	max-height: 100%;
 | 
			
		||||
	overflow: hidden;
 | 
			
		||||
	display: flex;
 | 
			
		||||
	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>
 | 
			
		||||
<MkModalWindow
 | 
			
		||||
	ref="dialog"
 | 
			
		||||
	:width="366"
 | 
			
		||||
	:height="500"
 | 
			
		||||
	:width="500"
 | 
			
		||||
	:height="600"
 | 
			
		||||
	@close="dialog.close()"
 | 
			
		||||
	@closed="$emit('closed')"
 | 
			
		||||
>
 | 
			
		||||
	<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"/>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
			</template>
 | 
			
		||||
		</Transition>
 | 
			
		||||
	</div>
 | 
			
		||||
</MkModalWindow>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
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 { i18n } from '@/i18n';
 | 
			
		||||
import { instance } from '@/instance';
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	autoSet?: boolean;
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +49,8 @@ const emit = defineEmits<{
 | 
			
		|||
 | 
			
		||||
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
 | 
			
		||||
 | 
			
		||||
const isAcceptedServerRule = $ref(false);
 | 
			
		||||
 | 
			
		||||
function onSignup(res) {
 | 
			
		||||
	emit('done', res);
 | 
			
		||||
	dialog.close();
 | 
			
		||||
| 
						 | 
				
			
			@ -42,3 +60,18 @@ function onSignupEmailPending() {
 | 
			
		|||
	dialog.close();
 | 
			
		||||
}
 | 
			
		||||
</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"
 | 
			
		||||
		@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>
 | 
			
		||||
	</span>
 | 
			
		||||
	<span class="label">
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,7 +41,7 @@
 | 
			
		|||
							<template #value>{{ instance.maintainerEmail }}</template>
 | 
			
		||||
						</MkKeyValue>
 | 
			
		||||
					</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>
 | 
			
		||||
			</FormSection>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,10 +3,15 @@
 | 
			
		|||
	<MkStickyContainer>
 | 
			
		||||
		<template #header><XHeader :tabs="headerTabs"/></template>
 | 
			
		||||
		<MkSpacer :content-max="700" :margin-min="16" :margin-max="32">
 | 
			
		||||
			<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
 | 
			
		||||
			<FormSuspense :p="init">
 | 
			
		||||
				<div class="_gaps_m">
 | 
			
		||||
					<FormSection first>
 | 
			
		||||
						<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">
 | 
			
		||||
								<template #label>{{ i18n.ts.sensitiveWords }}</template>
 | 
			
		||||
								<template #caption>{{ i18n.ts.sensitiveWordsDescription }}</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -41,16 +46,20 @@ import { fetchInstance } from '@/instance';
 | 
			
		|||
import { i18n } from '@/i18n';
 | 
			
		||||
import { definePageMetadata } from '@/scripts/page-metadata';
 | 
			
		||||
import MkButton from '@/components/MkButton.vue';
 | 
			
		||||
import FormLink from "@/components/form/link.vue";
 | 
			
		||||
 | 
			
		||||
let sensitiveWords: string = $ref('');
 | 
			
		||||
let tosUrl: string | null = $ref(null);
 | 
			
		||||
 | 
			
		||||
async function init() {
 | 
			
		||||
	const meta = await os.api('admin/meta');
 | 
			
		||||
	sensitiveWords = meta.sensitiveWords.join('\n');
 | 
			
		||||
	tosUrl = meta.tosUrl;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function save() {
 | 
			
		||||
	os.apiWithDialog('admin/update-meta', {
 | 
			
		||||
		tosUrl,
 | 
			
		||||
		sensitiveWords: sensitiveWords.split('\n'),
 | 
			
		||||
	}).then(() => {
 | 
			
		||||
		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>
 | 
			
		||||
					</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">
 | 
			
		||||
						<MkInput v-model="maintainerName">
 | 
			
		||||
							<template #label>{{ i18n.ts.maintainerName }}</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -169,7 +164,6 @@ import MkButton from '@/components/MkButton.vue';
 | 
			
		|||
 | 
			
		||||
let name: string | null = $ref(null);
 | 
			
		||||
let description: string | null = $ref(null);
 | 
			
		||||
let tosUrl: string | null = $ref(null);
 | 
			
		||||
let maintainerName: string | null = $ref(null);
 | 
			
		||||
let maintainerEmail: string | null = $ref(null);
 | 
			
		||||
let iconUrl: string | null = $ref(null);
 | 
			
		||||
| 
						 | 
				
			
			@ -194,7 +188,6 @@ async function init() {
 | 
			
		|||
	const meta = await os.api('admin/meta');
 | 
			
		||||
	name = meta.name;
 | 
			
		||||
	description = meta.description;
 | 
			
		||||
	tosUrl = meta.tosUrl;
 | 
			
		||||
	iconUrl = meta.iconUrl;
 | 
			
		||||
	bannerUrl = meta.bannerUrl;
 | 
			
		||||
	backgroundImageUrl = meta.backgroundImageUrl;
 | 
			
		||||
| 
						 | 
				
			
			@ -220,7 +213,6 @@ function save() {
 | 
			
		|||
	os.apiWithDialog('admin/update-meta', {
 | 
			
		||||
		name,
 | 
			
		||||
		description,
 | 
			
		||||
		tosUrl,
 | 
			
		||||
		iconUrl,
 | 
			
		||||
		bannerUrl,
 | 
			
		||||
		backgroundImageUrl,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -427,6 +427,10 @@ export const routes = [{
 | 
			
		|||
		path: '/other-settings',
 | 
			
		||||
		name: 'other-settings',
 | 
			
		||||
		component: page(() => import('./pages/admin/other-settings.vue')),
 | 
			
		||||
	}, {
 | 
			
		||||
		path: '/server-rules',
 | 
			
		||||
		name: 'server-rules',
 | 
			
		||||
		component: page(() => import('./pages/admin/server-rules.vue')),
 | 
			
		||||
	}, {
 | 
			
		||||
		path: '/',
 | 
			
		||||
		component: page(() => import('./pages/_empty_.vue')),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -164,7 +164,7 @@ export const defaultStore = markRaw(new Storage('base', {
 | 
			
		|||
	},
 | 
			
		||||
	animation: {
 | 
			
		||||
		where: 'device',
 | 
			
		||||
		default: !matchMedia('(prefers-reduced-motion)').matches,
 | 
			
		||||
		default: !window.matchMedia('(prefers-reduced-motion)').matches,
 | 
			
		||||
	},
 | 
			
		||||
	animatedMfm: {
 | 
			
		||||
		where: 'device',
 | 
			
		||||
| 
						 | 
				
			
			@ -188,7 +188,7 @@ export const defaultStore = markRaw(new Storage('base', {
 | 
			
		|||
	},
 | 
			
		||||
	disableShowingAnimatedImages: {
 | 
			
		||||
		where: 'device',
 | 
			
		||||
		default: matchMedia('(prefers-reduced-motion)').matches,
 | 
			
		||||
		default: window.matchMedia('(prefers-reduced-motion)').matches,
 | 
			
		||||
	},
 | 
			
		||||
	emojiStyle: {
 | 
			
		||||
		where: 'device',
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2348,6 +2348,7 @@ type LiteInstanceMetadata = {
 | 
			
		|||
        imageUrl: string;
 | 
			
		||||
    }[];
 | 
			
		||||
    translatorAvailable: boolean;
 | 
			
		||||
    serverRules: string[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// @public (undocumented)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -315,6 +315,7 @@ export type LiteInstanceMetadata = {
 | 
			
		|||
		imageUrl: string;
 | 
			
		||||
	}[];
 | 
			
		||||
	translatorAvailable: boolean;
 | 
			
		||||
	serverRules: string[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export type DetailedInstanceMetadata = LiteInstanceMetadata & {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue