Chg: exactOptionalPropertyTypesをfalseに変更 / Chg(Security): トークンが不正な場合のエラーを全てtoken_invalidに変更 / New: channelテーブル・リポジトリ / Chg: configテーブルのvalueをstringに / Chg: configテーブルのlengthを4096に / New: messageテーブル / Chg: 安全のためuserテーブルのOptionalPropsにidを追加 / New: channel/createエンドポイント / New: channel/listエンドポイント / New: channel/editエンドポイント / Enhance: primary/signupエンドポイントの重複エラーの実装で末尾カンマなどの改善 / Chg: setup/initializationのdescriptionに最大文字数4096を制定 / Chg: serverInfoをdefault exportからexportに変更 / New: フロントエンドでmeを読み込み / New: フロントエンドでchannelを読み込み / New: client.tsでトークンがある場合はトークンを指定 / Chg: clientをrefに / Del: IndexedDBからserverテーブルを削除 / Fix: Dexieのclassに命名 / Feat: フロントエンドでのサインインページ / Fix: L.jsで任意のbodyがあるエンドポイントが定義できない問題を修正 / Del: L.jsのserver-infoでの不要なimportを削除 / Fix: L.jsでトークンのエラーを追加 / Fix: L.jsのUserSchemaにlastUsedAtを追加

This commit is contained in:
2026-03-30 11:37:57 +09:00
parent 6b54ae4306
commit d129c95aa4
33 changed files with 683 additions and 39 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ main.layout {
import { RouterView, useRouter } from "vue-router";
import routerStatus from "@/lib/router";
import Progress from "@/components/Progress.vue";
import serverInfo from "@/lib/account";
import { serverInfo } from "@/lib/account";
const router = useRouter();
+50 -4
View File
@@ -1,11 +1,57 @@
import client from "@/lib/client";
import client, { initClient } from "@/lib/client";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import { ref } from "vue";
/*
[TODO]
キャッシュの類を全部account.tsに詰め込むのをやめる
*/
let serverInfo = ref<ApiMap["server-info"]["response"]>(await client.request("server-info"));
await initClient();
export let serverInfo = ref<ApiMap["server-info"]["response"]>(await client.value.request("server-info"));
export let account = ref<ApiMap["me"]["response"]>(await client.value.request("me"));
export let channels = ref<Extract<ApiMap["channel/list"]["response"], { channels: any }>["channels"]>([]);
export let lastLoadedChannel = ref<string>();
export const reloadServerInfo = async () => {
serverInfo.value = await client.request("server-info");
serverInfo.value = await client.value.request("server-info");
}
export default serverInfo;
export const reloadAccount = async () => {
await initClient();
account.value = await client.value.request("me");
}
export const initChannels = async () => {
lastLoadedChannel.value = undefined;
const response = await client.value.request("channel/list");
if (!response.success) {
return false;
}
channels.value = response.channels;
if (response.channels.length > 0) {
lastLoadedChannel.value = response.channels[response.channels.length - 1]?.id;
}
return true;
}
export const loadChannels = async () => {
const response = await client.value.request("channel/list", lastLoadedChannel.value ? {
since: lastLoadedChannel.value,
} : undefined);
if (!response.success) {
return false;
}
channels.value = channels.value.concat(response.channels);
if (response.channels.length > 0) {
lastLoadedChannel.value = response.channels[response.channels.length - 1]?.id;
}
return true;
}
+13 -2
View File
@@ -1,8 +1,19 @@
import LynqChat from "lynqchat-js";
import type ApiMap from "lynqchat-js/1.0.0-alpha.0/map";
import Database, { getByIndex } from "./db";
import { ref } from "vue";
const client = new LynqChat<ApiMap>({
const client = ref<LynqChat<ApiMap>>(new LynqChat<ApiMap>({
origin: window.origin,
});
}));
export const initClient = async () => {
const db = new Database();
const row = await getByIndex(db.settings, "name", "token");
if (row?.value) {
client.value.token = row.value;
}
}
export default client;
+1 -8
View File
@@ -6,14 +6,7 @@ export interface Settings {
value: string;
}
export interface Server {
id: number;
name: string;
value: string;
}
export default class extends Dexie {
server!: EntityTable<Server, "id">;
export default class Database extends Dexie {
settings!: EntityTable<Settings, "id">;
constructor() {
+8 -1
View File
@@ -2,7 +2,7 @@ import { createApp } from "vue";
import { createRouter, createWebHistory } from "vue-router";
import routerStatus from "@/lib/router";
import { createHead } from "@unhead/vue/client";
import serverInfo from "@/lib/account";
import { serverInfo } from "@/lib/account";
import "@/global.css";
import Layout from "@/Layout.vue";
@@ -39,6 +39,13 @@ const router = createRouter({
},
component: () => import("@/routes/setup/create-admin.vue"),
},
{
path: "/signin",
meta: {
title: "サインイン",
},
component: () => import("@/routes/signin.vue"),
},
{
path: "/:NotFound(.*)*",
meta: {
+8
View File
@@ -3,4 +3,12 @@
</template>
<script lang="ts" setup>
import { account } from "@/lib/account";
import { useRouter } from "vue-router";
const router = useRouter();
if (!account.value.success) {
router.replace("/signin");
}
</script>
@@ -89,7 +89,7 @@ form {
</style>
<script lang="ts" setup>
import serverInfo, { reloadServerInfo } from "@/lib/account";
import { serverInfo, reloadServerInfo } from "@/lib/account";
import { useRouter } from "vue-router";
import Input from "@/components/Input.vue";
import Button from "@/components/Button.vue";
@@ -169,7 +169,7 @@ const submit = async (e: Event) => {
});
}
const response = await client.request("setup/create-admin", result.value.data);
const response = await client.value.request("setup/create-admin", result.value.data);
if (!response.success) {
closeLoadingModal();
@@ -191,7 +191,7 @@ const submit = async (e: Event) => {
onClose: async () => {
isProcessing.value = false;
await reloadServerInfo();
router.push("/");
router.push("/signin");
}
});
}
@@ -87,7 +87,7 @@ form {
</style>
<script lang="ts" setup>
import serverInfo, { reloadServerInfo } from "@/lib/account";
import { serverInfo, reloadServerInfo } from "@/lib/account";
import { useRouter } from "vue-router";
import Input from "@/components/Input.vue";
import Toggle from "@/components/Toggle.vue";
@@ -163,7 +163,7 @@ const submit = async (e: Event) => {
});
}
const response = await client.request("setup/initialization", result.value.data);
const response = await client.value.request("setup/initialization", result.value.data);
if (!response.success) {
closeLoadingModal();
+165
View File
@@ -0,0 +1,165 @@
<template>
<form novalidate @submit="submit">
<Input
label="ユーザーID"
autocomplete="off"
v-model="userid"
>
<span
class="input-issue"
v-if="useridIssue"
>
<Icon icon="material-symbols:error-outline-rounded" />
{{ useridIssue.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>
</InputPassword>
<Button
name="サインイン"
type="submit"
color="accent"
:disabled="!result.success || isProcessing"
/>
</form>
</template>
<style scoped>
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.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;
}
</style>
<script lang="ts" setup>
import Button from "@/components/Button.vue";
import Input from "@/components/Input.vue";
import InputPassword from "@/components/InputPassword.vue";
import Loading from "@/components/Modal/Loading.vue";
import Success from "@/components/Modal/Success.vue";
import { account, reloadAccount, serverInfo } from "@/lib/account";
import client from "@/lib/client";
import Database from "@/lib/db";
import { createModal } from "@/lib/modal";
import { getIssueFromPath } from "@/lib/validation";
import { Icon } from "@iconify/vue";
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import z from "zod/v3";
const router = useRouter();
const isProcessing = ref<boolean>(false);
if (account.value.success) {
router.replace("/");
}
if (!serverInfo.value.success) {
throw new Error();
}
if (!serverInfo.value.isInitialized) {
router.replace("/setup/initialization");
}
const userid = ref<string>("");
const password = ref<string>("");
const useridIssue = computed(() => getIssueFromPath("userid", result.value));
const passwordIssue = computed(() => getIssueFromPath("password", result.value));
const schema = z.object({
userid: z.string({ message: "文字列で入力してください。" })
.trim().min(3, "3文字以上で入力してください。")
.max(20, "20文字以内で入力してください。"),
password: z.string({ message: "文字列で入力してください。" })
.trim().min(8, "8文字以上で入力してください。"),
});
const result = computed(() => schema.safeParse({
userid: userid.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: Error,
onClose: () => isProcessing.value = false,
props: {
error: `不正な入力です。<br>${messages.join("<br>")}`,
canClose: true,
},
});
}
const response = await client.value.request("primary/signin", result.value.data);
if (!response.success) {
closeLoadingModal();
return createModal({
component: Error,
onClose: () => isProcessing.value = false,
props: {
error: response.error.message,
canClose: true,
},
});
}
const db = new Database();
await db.settings.add({
name: "token",
value: response.token,
});
await reloadAccount();
closeLoadingModal();
return createModal({
component: Success,
onClose: async () => {
isProcessing.value = false;
router.push("/");
}
});
}
</script>