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以上が必要になりました
 | 
					- 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;">
 | 
				
			||||||
		<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
 | 
							<Transition
 | 
				
			||||||
	</MkSpacer>
 | 
								mode="out-in"
 | 
				
			||||||
 | 
								:enter-active-class="$style.transition_x_enterActive"
 | 
				
			||||||
 | 
								:leave-active-class="$style.transition_x_leaveActive"
 | 
				
			||||||
 | 
								:enter-from-class="$style.transition_x_enterFrom"
 | 
				
			||||||
 | 
								:leave-to-class="$style.transition_x_leaveTo"
 | 
				
			||||||
 | 
							>
 | 
				
			||||||
 | 
								<template v-if="!isAcceptedServerRule">
 | 
				
			||||||
 | 
									<XServerRules @accept="isAcceptedServerRule = true"/>
 | 
				
			||||||
 | 
								</template>
 | 
				
			||||||
 | 
								<template v-else>
 | 
				
			||||||
 | 
									<XSignup :auto-set="autoSet" @signup="onSignup" @signup-email-pending="onSignupEmailPending"/>
 | 
				
			||||||
 | 
								</template>
 | 
				
			||||||
 | 
							</Transition>
 | 
				
			||||||
 | 
						</div>
 | 
				
			||||||
</MkModalWindow>
 | 
					</MkModalWindow>
 | 
				
			||||||
</template>
 | 
					</template>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
<script lang="ts" setup>
 | 
					<script lang="ts" setup>
 | 
				
			||||||
import { } from 'vue';
 | 
					import { } from 'vue';
 | 
				
			||||||
import XSignup from '@/components/MkSignup.vue';
 | 
					import { $ref } from 'vue/macros';
 | 
				
			||||||
 | 
					import XSignup from '@/components/MkSignupDialog.form.vue';
 | 
				
			||||||
 | 
					import XServerRules from '@/components/MkSignupDialog.rules.vue';
 | 
				
			||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
 | 
					import MkModalWindow from '@/components/MkModalWindow.vue';
 | 
				
			||||||
import { i18n } from '@/i18n';
 | 
					import { i18n } from '@/i18n';
 | 
				
			||||||
 | 
					import { instance } from '@/instance';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const props = withDefaults(defineProps<{
 | 
					const props = withDefaults(defineProps<{
 | 
				
			||||||
	autoSet?: boolean;
 | 
						autoSet?: boolean;
 | 
				
			||||||
| 
						 | 
					@ -33,6 +49,8 @@ const emit = defineEmits<{
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
 | 
					const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const isAcceptedServerRule = $ref(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function onSignup(res) {
 | 
					function onSignup(res) {
 | 
				
			||||||
	emit('done', res);
 | 
						emit('done', res);
 | 
				
			||||||
	dialog.close();
 | 
						dialog.close();
 | 
				
			||||||
| 
						 | 
					@ -42,3 +60,18 @@ function onSignupEmailPending() {
 | 
				
			||||||
	dialog.close();
 | 
						dialog.close();
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
</script>
 | 
					</script>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<style lang="scss" module>
 | 
				
			||||||
 | 
					.transition_x_enterActive,
 | 
				
			||||||
 | 
					.transition_x_leaveActive {
 | 
				
			||||||
 | 
						transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.transition_x_enterFrom {
 | 
				
			||||||
 | 
						opacity: 0;
 | 
				
			||||||
 | 
						transform: translateX(50px);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					.transition_x_leaveTo {
 | 
				
			||||||
 | 
						opacity: 0;
 | 
				
			||||||
 | 
						transform: translateX(-50px);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					</style>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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…
	
	Add table
		Add a link
		
	
		Reference in a new issue