Feat: #10 サインアップ / Chg: server-infoでconfigの取得にconfigRepo.get()を使うように / Fix: アカウントが無効でもコミュニティを作成ボタンがある問題
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { EntityRepository } from "@mikro-orm/postgresql";
|
||||
import { UserEntity } from "@/modules/entities/User";
|
||||
import { ConfigEntity } from "@/modules/entities/Config";
|
||||
import { hash, argon2id, verify as argon2Verify } from "argon2";
|
||||
import z from "zod/v3";
|
||||
import EmailRegex from "@/regexs/email";
|
||||
import { ErrorBase } from "@/errors";
|
||||
|
||||
export class UserRepository extends EntityRepository<UserEntity> {
|
||||
public static schema = z.object({
|
||||
@@ -15,7 +17,18 @@ export class UserRepository extends EntityRepository<UserEntity> {
|
||||
isAdmin: z.boolean(),
|
||||
});
|
||||
|
||||
async createUser(data: Pick<z.infer<typeof UserRepository.schema>, "userid" | "email" | "password" | "isAdmin">) {
|
||||
async createUser(data: Pick<z.infer<typeof UserRepository.schema>, "userid" | "email" | "password" | "isAdmin"> & {
|
||||
invitationCode?: string;
|
||||
}) {
|
||||
const requiredInvitationCode = await this.em.getRepository(ConfigEntity).get("requiredInvitationCode", "true") as string;
|
||||
if (requiredInvitationCode && !data.invitationCode) {
|
||||
return ErrorBase({
|
||||
bad: "client",
|
||||
code: "invitation_code_invalid",
|
||||
message: "招待コードが無効です。",
|
||||
});
|
||||
}
|
||||
|
||||
const hashed = await hash(data.password, {
|
||||
type: argon2id,
|
||||
memoryCost: 2 ** 16,
|
||||
|
||||
@@ -3,6 +3,8 @@ import Logger from "@/lib/logger";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { UserRepository } from "@/modules/repositories/User";
|
||||
import { DatabaseError, InputError } from "@/errors";
|
||||
import { ConfigEntity } from "@/modules/entities/Config";
|
||||
import z, { ZodObject } from "zod/v3";
|
||||
|
||||
export default function PrimarySignUp(fastify: FastifyInstance) {
|
||||
const logger = new Logger("Endpoint | primary/signup");
|
||||
@@ -10,23 +12,38 @@ export default function PrimarySignUp(fastify: FastifyInstance) {
|
||||
fastify.post("/", async (req, res) => {
|
||||
res.header("Content-Type", "application/json");
|
||||
|
||||
const result = UserRepository.schema.pick({
|
||||
userid: true,
|
||||
email: true,
|
||||
password: true,
|
||||
}).safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(result.error.issues)
|
||||
return res.code(400).send(InputError(result.error.issues));
|
||||
}
|
||||
|
||||
try {
|
||||
await fastify.orm.em.getRepository(UserEntity).createUser({
|
||||
...result.data,
|
||||
const requiredInvitationCode = await fastify.orm.em.getRepository(ConfigEntity).get("requiredInvitationCode", "true") as string;
|
||||
|
||||
let reqInvCodeSchema: ZodObject<any>;
|
||||
if (requiredInvitationCode !== "true") {
|
||||
reqInvCodeSchema = z.object({});
|
||||
} else {
|
||||
reqInvCodeSchema = z.object({
|
||||
invitationCode: z.string().trim().min(1),
|
||||
});
|
||||
}
|
||||
|
||||
const result = UserRepository.schema.pick({
|
||||
userid: true,
|
||||
email: true,
|
||||
password: true,
|
||||
}).merge(reqInvCodeSchema).safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
console.log(result.error.issues)
|
||||
return res.code(400).send(InputError(result.error.issues));
|
||||
}
|
||||
|
||||
const error = await fastify.orm.em.getRepository(UserEntity).createUser({
|
||||
...(result.data as any),
|
||||
isAdmin: false,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return res.code(400).send(error);
|
||||
}
|
||||
|
||||
return res.code(200).send({
|
||||
success: true,
|
||||
});
|
||||
|
||||
@@ -15,15 +15,15 @@ export default async function ServerInfo(fastify: FastifyInstance) {
|
||||
const configCount = await configRepo.count();
|
||||
const userCount = await userRepo.count();
|
||||
|
||||
const serverName = await configRepo.findOne({ name: "name" });
|
||||
const serverDescription = await configRepo.findOne({ name: "description" });
|
||||
const serverIcon = await configRepo.findOne({ name: "icon" });
|
||||
const serverName = await configRepo.get("name");
|
||||
const serverDescription = await configRepo.get("description");
|
||||
const serverIcon = await configRepo.get("icon");
|
||||
|
||||
return res.send({
|
||||
success: true,
|
||||
name: serverName?.value ?? null,
|
||||
description: serverDescription?.value ?? null,
|
||||
icon: serverIcon?.value ?? null,
|
||||
name: serverName ?? null,
|
||||
description: serverDescription ?? null,
|
||||
icon: serverIcon ?? null,
|
||||
isInitialized: configCount > 0,
|
||||
isFirstAdminExists: userCount > 0,
|
||||
userCount,
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
</RouterLink>
|
||||
|
||||
<div
|
||||
v-if="account?.success"
|
||||
@click="createCommunity()"
|
||||
title="コミュニティを作成"
|
||||
>
|
||||
@@ -245,7 +246,7 @@ import { RouterView, RouterLink, useRouter, useRoute } from "vue-router";
|
||||
import routerStatus, { title } from "@/lib/router";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import Progress from "@/components/Progress.vue";
|
||||
import { communitys, serverInfo } from "@/lib/account";
|
||||
import { account, communitys, serverInfo } from "@/lib/account";
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
|
||||
import { createModal } from "@/lib/modal";
|
||||
import ErrorModal from "@/components/Modal/Error.vue";
|
||||
|
||||
@@ -46,6 +46,13 @@ const router = createRouter({
|
||||
},
|
||||
component: () => import("@/routes/signin.vue"),
|
||||
},
|
||||
{
|
||||
path: "/signup",
|
||||
meta: {
|
||||
title: "サインアップ",
|
||||
},
|
||||
component: () => import("@/routes/signup.vue"),
|
||||
},
|
||||
{
|
||||
path: "/community/:communityId",
|
||||
meta: {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
color="accent"
|
||||
:disabled="!result.success || isProcessing"
|
||||
/>
|
||||
|
||||
<RouterLink to="/signup">アカウントをお持ちでないですか?</RouterLink>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<p><!--
|
||||
-->ここでは、サインアップを行います。<!--
|
||||
-->アカウントは、端末に1つのものではなく、IDとパスワードでどんな端末でも使いまわすことが出来ます。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form novalidate @submit="submit">
|
||||
<Input
|
||||
label="ユーザーID"
|
||||
autocomplete="username"
|
||||
v-model="userid"
|
||||
>
|
||||
<span
|
||||
class="input-issue"
|
||||
v-if="useridIssue"
|
||||
>
|
||||
<Icon icon="material-symbols:error-outline-rounded" />
|
||||
{{ useridIssue.message }}
|
||||
</span>
|
||||
</Input>
|
||||
|
||||
<Input
|
||||
label="メールアドレス"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
v-model="email"
|
||||
>
|
||||
<span
|
||||
class="input-issue"
|
||||
v-if="emailIssue"
|
||||
>
|
||||
<Icon icon="material-symbols:error-outline-rounded" />
|
||||
{{ emailIssue.message }}
|
||||
</span>
|
||||
</Input>
|
||||
|
||||
<InputPassword
|
||||
label="パスワード"
|
||||
v-model="password"
|
||||
>
|
||||
<span
|
||||
class="input-issue"
|
||||
v-if="passwordIssue"
|
||||
>
|
||||
<Icon icon="material-symbols:error-outline-rounded" />
|
||||
{{ passwordIssue.message }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
class="input-issue password-strength"
|
||||
v-if="passwordStrength.message"
|
||||
>
|
||||
<Icon :icon="passwordStrength.icon" />
|
||||
{{ passwordStrength.message }}
|
||||
</span>
|
||||
</InputPassword>
|
||||
|
||||
<Button
|
||||
name="サインアップ"
|
||||
type="submit"
|
||||
color="accent"
|
||||
:disabled="!result.success || isProcessing"
|
||||
/>
|
||||
|
||||
<RouterLink to="/signin">アカウントをお持ちですか?</RouterLink>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.input-issue {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--error-color);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.input-issue svg {
|
||||
font-size: 1rem;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
.password-strength {
|
||||
color: v-bind(passwordStrengthColor);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { serverInfo, reloadServerInfo } from "@/lib/account";
|
||||
import { useRouter } from "vue-router";
|
||||
import Input from "@/components/Input.vue";
|
||||
import Button from "@/components/Button.vue";
|
||||
import { ref } from "vue";
|
||||
import z from "zod/v3";
|
||||
import { computed } from "vue";
|
||||
import zxcvbn from "zxcvbn";
|
||||
import { createModal } from "@/lib/modal";
|
||||
import Loading from "@/components/Modal/Loading.vue";
|
||||
import ErrorModal from "@/components/Modal/Error.vue";
|
||||
import Success from "@/components/Modal/Success.vue";
|
||||
import InputPassword from "@/components/InputPassword.vue";
|
||||
import client from "@/lib/client";
|
||||
import { getIssueFromPath } from "@/lib/validation";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
const router = useRouter();
|
||||
const isProcessing = ref<boolean>(false);
|
||||
|
||||
if (!serverInfo.value?.success) {
|
||||
throw new Error("サーバー情報の取得に失敗しました。");
|
||||
}
|
||||
|
||||
if (!serverInfo.value.isInitialized) {
|
||||
router.replace("/setup/initialization");
|
||||
}
|
||||
|
||||
const userid = ref<string>("");
|
||||
const email = ref<string>("");
|
||||
const password = ref<string>("");
|
||||
const useridIssue = computed(() => getIssueFromPath("userid", result.value));
|
||||
const emailIssue = computed(() => getIssueFromPath("email", result.value));
|
||||
const passwordIssue = computed(() => getIssueFromPath("password", result.value));
|
||||
|
||||
const passwordScores = [
|
||||
["危険なパスワード", "error-outline-rounded", "var(--error-color)"],
|
||||
["推測可能なパスワード", "warning-rounded", "var(--warn-color)"],
|
||||
["推測しやすいパスワード", "warning-rounded", "var(--warn-color)"],
|
||||
["安全なパスワード", "check-circle", "var(--success-color)"],
|
||||
["強力なパスワード", "check-circle", "var(--success-color)"],
|
||||
];
|
||||
const passwordStrength = computed(() => {
|
||||
const evaluation = zxcvbn(password.value);
|
||||
return {
|
||||
message: passwordScores[evaluation.score]![0],
|
||||
score: evaluation.score,
|
||||
icon: "material-symbols:" + passwordScores[evaluation.score]![1],
|
||||
color: passwordScores[evaluation.score]![2],
|
||||
}
|
||||
});
|
||||
const passwordStrengthColor = computed(() => passwordStrength.value.color);
|
||||
|
||||
const EmailRegex = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:([0-9]{1,3}\.){3}[0-9]{1,3})\])$/i;
|
||||
const schema = z.object({
|
||||
userid: z.string({ message: "文字列で入力してください。" })
|
||||
.trim().min(3, "3文字以上で入力してください。")
|
||||
.max(20, "20文字以内で入力してください。"),
|
||||
email: z.string({ message: "文字列で入力してください。" })
|
||||
.trim().min(6, "6文字以上で入力してください。")
|
||||
.max(254, "254文字以内で入力してください。")
|
||||
.regex(EmailRegex, "形式が異なります。"),
|
||||
password: z.string({ message: "文字列で入力してください。" })
|
||||
.trim().min(8, "8文字以上で入力してください。"),
|
||||
});
|
||||
|
||||
const result = computed(() => schema.safeParse({
|
||||
userid: userid.value,
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
}));
|
||||
|
||||
const submit = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
isProcessing.value = true;
|
||||
|
||||
const closeLoadingModal = createModal({
|
||||
component: Loading,
|
||||
});
|
||||
|
||||
if (!result.value.success) {
|
||||
const messages = result.value.error.issues.map(issue => issue.message);
|
||||
|
||||
closeLoadingModal();
|
||||
|
||||
return createModal({
|
||||
component: ErrorModal,
|
||||
onClose: () => isProcessing.value = false,
|
||||
props: {
|
||||
error: `不正な入力です。<br>${messages.join("<br>")}`,
|
||||
canClose: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const response = await client.value.request("setup/create-admin", result.value.data);
|
||||
|
||||
if (!response.success) {
|
||||
closeLoadingModal();
|
||||
|
||||
return createModal({
|
||||
component: ErrorModal,
|
||||
onClose: () => isProcessing.value = false,
|
||||
props: {
|
||||
error: response.error.message,
|
||||
canClose: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
closeLoadingModal();
|
||||
|
||||
return createModal({
|
||||
component: Success,
|
||||
onClose: async () => {
|
||||
isProcessing.value = false;
|
||||
await reloadServerInfo();
|
||||
router.push("/signin");
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user