Feat: #10 サインアップ / Chg: server-infoでconfigの取得にconfigRepo.get()を使うように / Fix: アカウントが無効でもコミュニティを作成ボタンがある問題

This commit is contained in:
2026-06-01 18:31:05 +09:00
parent 45a682b6ca
commit c725ae41bf
7 changed files with 288 additions and 21 deletions
@@ -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,
+30 -13
View File
@@ -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,
});
+6 -6
View File
@@ -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,
+2 -1
View File
@@ -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";
+7
View File
@@ -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: {
+2
View File
@@ -33,6 +33,8 @@
color="accent"
:disabled="!result.success || isProcessing"
/>
<RouterLink to="/signup">アカウントをお持ちでないですか</RouterLink>
</form>
</template>
+227
View File
@@ -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>