Update app configuration and account flows
This commit is contained in:
parent
7cc2f51594
commit
5d5be69603
12
.env.example
12
.env.example
|
|
@ -1,6 +1,13 @@
|
|||
# Production/public domain. Must resolve to the VPS running frps.
|
||||
DOMAIN=cents.homemade.net.cn
|
||||
|
||||
# App install identity. Use "dev" for cents-dev and "pro" for cents-pro.
|
||||
APP_VARIANT=dev
|
||||
# Optional overrides for the PWA install name/id.
|
||||
# VITE_APP_NAME=Cents Dev
|
||||
# VITE_APP_SHORT_NAME=Cents Dev
|
||||
# VITE_APP_ID=/?app=cents-dev
|
||||
|
||||
# Public VPS / frps address.
|
||||
FRPC_SERVER_HOST=119.28.12.24
|
||||
FRPC_SERVER_PORT=7000
|
||||
|
|
@ -12,3 +19,8 @@ APP_PROTOCOL=http
|
|||
|
||||
# Use false only after the tunnel is known to work.
|
||||
TEST_MODE=false
|
||||
|
||||
# API and PostgreSQL. The development database is provided by compose.dev.yaml.
|
||||
DATABASE_URL=postgres://cents:cents-dev-only@127.0.0.1:55432/cents
|
||||
PUBLIC_ORIGIN=https://dev.cents.homemade.net.cn
|
||||
COOKIE_SECURE=true
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
- [产品边界](docs/产品边界.md)
|
||||
- [技术方案](docs/技术方案.md)
|
||||
- [数据与同步](docs/数据与同步.md)
|
||||
- [账号与权限](docs/账号与权限.md)
|
||||
- [测试策略](docs/测试策略.md)
|
||||
- [内网穿透调试](docs/内网穿透调试.md)
|
||||
- [HTML 原型图](docs/原型图/原型说明.md)
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"name": "Cents",
|
||||
"short_name": "Cents",
|
||||
"description": "家庭共享账本",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f7f4ee",
|
||||
"theme_color": "#f7f4ee",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const CACHE_NAME = "cents-shell-v2";
|
||||
const CACHE_NAME = "cents-shell-v3";
|
||||
const APP_SHELL = ["/", "/manifest.webmanifest", "/icon.svg"];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import { computed, onMounted, ref } from "vue";
|
|||
import { useRoute } from "vue-router";
|
||||
import { createEntry } from "../data/entries";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
import QuickEntryDrawer from "./QuickEntryDrawer.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const entryStore = useEntryStore();
|
||||
const authStore = useAuthStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const drawerOpen = ref(false);
|
||||
const savedToast = ref(false);
|
||||
|
|
@ -38,6 +40,7 @@ async function saveEntry(input: {
|
|||
...input,
|
||||
ledgerId: ledger.id,
|
||||
currency: ledger.defaultCurrency,
|
||||
createdBy: authStore.user?.id,
|
||||
}));
|
||||
drawerOpen.value = false;
|
||||
savedToast.value = true;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(
|
||||
path: string,
|
||||
options: { method?: string; body?: unknown } = {},
|
||||
): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: options.method ?? "GET",
|
||||
credentials: "same-origin",
|
||||
headers: options.body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({})) as { error?: string };
|
||||
if (!response.ok) throw new ApiError(payload.error ?? "请求失败", response.status);
|
||||
return payload as T;
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ type EntryInput = {
|
|||
categoryId: string;
|
||||
note: string;
|
||||
occurredAt: string;
|
||||
createdBy?: string;
|
||||
};
|
||||
|
||||
const baseCurrency: CurrencyCode = "CNY";
|
||||
|
|
@ -29,8 +30,8 @@ export function createEntry(input: EntryInput): LedgerEntry {
|
|||
categoryId: input.categoryId,
|
||||
note: input.note,
|
||||
occurredAt: input.occurredAt,
|
||||
createdBy: "local-user",
|
||||
updatedBy: "local-user",
|
||||
createdBy: input.createdBy ?? "local-user",
|
||||
updatedBy: input.createdBy ?? "local-user",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type LedgerRecord = {
|
|||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt: string | null;
|
||||
role?: "owner" | "member";
|
||||
};
|
||||
|
||||
export type UserPreference = {
|
||||
|
|
@ -18,8 +19,9 @@ export type UserPreference = {
|
|||
updatedAt: string;
|
||||
};
|
||||
|
||||
export const localUserId = "local-user";
|
||||
export const currentLedgerPreferenceId = `${localUserId}:currentLedgerId`;
|
||||
export function currentLedgerPreferenceId(userId: string) {
|
||||
return `${userId}:currentLedgerId`;
|
||||
}
|
||||
|
||||
export function createDefaultLedgers(): LedgerRecord[] {
|
||||
const now = new Date().toISOString();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import LedgerView from "./views/LedgerView.vue";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
|
|
@ -9,6 +10,23 @@ export const router = createRouter({
|
|||
name: "ledger",
|
||||
component: LedgerView,
|
||||
},
|
||||
{
|
||||
path: "/invite",
|
||||
name: "invite",
|
||||
component: () => import("./views/InviteView.vue"),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: () => import("./views/LoginView.vue"),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: "/join-ledger",
|
||||
name: "join-ledger",
|
||||
component: () => import("./views/JoinLedgerView.vue"),
|
||||
},
|
||||
{
|
||||
path: "/prototype/entry-detail",
|
||||
name: "entry-detail-prototype",
|
||||
|
|
@ -41,3 +59,14 @@ export const router = createRouter({
|
|||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore();
|
||||
await auth.loadSession();
|
||||
if (to.meta.public) {
|
||||
if (to.name === "login" && auth.user) return { name: "ledger" };
|
||||
return true;
|
||||
}
|
||||
if (!auth.user) return { name: "login", query: { redirect: to.fullPath } };
|
||||
return true;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { apiRequest } from "../data/api";
|
||||
|
||||
export type SessionUser = { id: string; name: string };
|
||||
|
||||
let sessionPromise: Promise<void> | null = null;
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
user: null as SessionUser | null,
|
||||
loaded: false,
|
||||
unavailable: false,
|
||||
}),
|
||||
actions: {
|
||||
async loadSession(force = false) {
|
||||
if (this.loaded && !force) return;
|
||||
if (sessionPromise) return sessionPromise;
|
||||
sessionPromise = (async () => {
|
||||
try {
|
||||
const result = await apiRequest<{ user: SessionUser | null }>("/api/auth/session");
|
||||
this.user = result.user;
|
||||
this.unavailable = false;
|
||||
} catch {
|
||||
this.user = null;
|
||||
this.unavailable = true;
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await sessionPromise;
|
||||
} finally {
|
||||
sessionPromise = null;
|
||||
}
|
||||
},
|
||||
async updateName(name: string) {
|
||||
const result = await apiRequest<{ user: SessionUser }>("/api/me", {
|
||||
method: "PATCH",
|
||||
body: { name },
|
||||
});
|
||||
this.user = result.user;
|
||||
},
|
||||
async login(name: string, password: string) {
|
||||
const result = await apiRequest<{ user: SessionUser }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: { name, password },
|
||||
});
|
||||
this.user = result.user;
|
||||
this.loaded = true;
|
||||
this.unavailable = false;
|
||||
},
|
||||
async logout() {
|
||||
await apiRequest("/api/auth/logout", { method: "POST" });
|
||||
this.user = null;
|
||||
this.loaded = true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { defineStore } from "pinia";
|
||||
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
||||
import { db } from "../data/db";
|
||||
import { createDemoEntries } from "../data/entries";
|
||||
import { createId } from "../data/ids";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
export const useEntryStore = defineStore("entries", {
|
||||
state: () => ({
|
||||
|
|
@ -10,9 +10,6 @@ export const useEntryStore = defineStore("entries", {
|
|||
}),
|
||||
actions: {
|
||||
async loadEntries() {
|
||||
if (import.meta.env.DEV && (await db.entries.count()) === 0) {
|
||||
await db.entries.bulkAdd(createDemoEntries());
|
||||
}
|
||||
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
|
||||
(entry) => entry.deletedAt === null,
|
||||
);
|
||||
|
|
@ -37,6 +34,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
await this.loadEntries();
|
||||
},
|
||||
async updateEntry(entry: LedgerEntry) {
|
||||
const auth = useAuthStore();
|
||||
const existing = await db.entries.get(entry.id);
|
||||
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
||||
|
||||
|
|
@ -44,7 +42,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
...existing,
|
||||
...entry,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: "local-user",
|
||||
updatedBy: auth.user?.id ?? existing.updatedBy,
|
||||
version: existing.version + 1,
|
||||
};
|
||||
const operation: SyncOperation = {
|
||||
|
|
@ -67,6 +65,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
return updated;
|
||||
},
|
||||
async deleteEntry(entryId: string) {
|
||||
const auth = useAuthStore();
|
||||
const existing = await db.entries.get(entryId);
|
||||
if (!existing || existing.deletedAt) return;
|
||||
|
||||
|
|
@ -75,7 +74,7 @@ export const useEntryStore = defineStore("entries", {
|
|||
...existing,
|
||||
deletedAt,
|
||||
updatedAt: deletedAt,
|
||||
updatedBy: "local-user",
|
||||
updatedBy: auth.user?.id ?? existing.updatedBy,
|
||||
version: existing.version + 1,
|
||||
};
|
||||
const operation: SyncOperation = {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { apiRequest } from "../data/api";
|
||||
import { db } from "../data/db";
|
||||
import {
|
||||
createDefaultLedgers,
|
||||
currentLedgerPreferenceId,
|
||||
localUserId,
|
||||
type LedgerRecord,
|
||||
type UserPreference,
|
||||
} from "../data/ledgers";
|
||||
import { currentLedgerPreferenceId, type LedgerRecord, type UserPreference } from "../data/ledgers";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
export type LedgerMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "owner" | "member";
|
||||
joinedAt: string;
|
||||
};
|
||||
|
||||
let ledgerLoadPromise: Promise<void> | null = null;
|
||||
|
||||
|
|
@ -14,29 +17,45 @@ export const useLedgerStore = defineStore("ledgers", {
|
|||
state: () => ({
|
||||
ledgers: [] as LedgerRecord[],
|
||||
currentLedgerId: "",
|
||||
currentMembers: [] as LedgerMember[],
|
||||
loaded: false,
|
||||
}),
|
||||
getters: {
|
||||
currentLedger(state) {
|
||||
return state.ledgers.find((ledger) => ledger.id === state.currentLedgerId) ?? null;
|
||||
},
|
||||
currentRole(): "owner" | "member" | null {
|
||||
return this.currentLedger?.role ?? null;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
async loadLedgers() {
|
||||
if (this.loaded) return;
|
||||
if (ledgerLoadPromise) return ledgerLoadPromise;
|
||||
ledgerLoadPromise = (async () => {
|
||||
if ((await db.ledgers.count()) === 0) await db.ledgers.bulkAdd(createDefaultLedgers());
|
||||
this.ledgers = (await db.ledgers.orderBy("updatedAt").reverse().toArray()).filter(
|
||||
(ledger) => ledger.archivedAt === null,
|
||||
);
|
||||
const auth = useAuthStore();
|
||||
if (!auth.user) return;
|
||||
const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers");
|
||||
const serverLedgers = result.ledgers.map((ledger) => ({ ...ledger }));
|
||||
this.ledgers = serverLedgers;
|
||||
|
||||
const preference = await db.userPreferences.get(currentLedgerPreferenceId);
|
||||
const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id));
|
||||
const inaccessibleEntries = await db.entries.filter((entry) => !allowedIds.has(entry.ledgerId)).primaryKeys();
|
||||
const inaccessibleOperations = await db.syncOperations.filter((item) => !allowedIds.has(item.ledgerId)).primaryKeys();
|
||||
await db.transaction("rw", db.ledgers, db.entries, db.syncOperations, async () => {
|
||||
await db.ledgers.clear();
|
||||
if (serverLedgers.length) await db.ledgers.bulkPut(serverLedgers);
|
||||
if (inaccessibleEntries.length) await db.entries.bulkDelete(inaccessibleEntries);
|
||||
if (inaccessibleOperations.length) await db.syncOperations.bulkDelete(inaccessibleOperations);
|
||||
});
|
||||
|
||||
const preferenceId = currentLedgerPreferenceId(auth.user.id);
|
||||
const preference = await db.userPreferences.get(preferenceId);
|
||||
const preferredLedger = this.ledgers.find((ledger) => ledger.id === preference?.value);
|
||||
const fallbackLedger = this.ledgers.find((ledger) => ledger.id === "local-ledger") ?? this.ledgers[0];
|
||||
this.currentLedgerId = preferredLedger?.id ?? fallbackLedger?.id ?? "";
|
||||
this.currentLedgerId = preferredLedger?.id ?? this.ledgers[0]?.id ?? "";
|
||||
if (this.currentLedgerId !== preference?.value) await this.persistCurrentLedger();
|
||||
this.loaded = true;
|
||||
await this.loadCurrentMembers();
|
||||
})();
|
||||
try {
|
||||
await ledgerLoadPromise;
|
||||
|
|
@ -44,36 +63,47 @@ export const useLedgerStore = defineStore("ledgers", {
|
|||
ledgerLoadPromise = null;
|
||||
}
|
||||
},
|
||||
async reloadLedgers() {
|
||||
this.loaded = false;
|
||||
this.currentMembers = [];
|
||||
await this.loadLedgers();
|
||||
},
|
||||
async setCurrentLedger(ledgerId: string) {
|
||||
const ledger = this.ledgers.find((item) => item.id === ledgerId && item.archivedAt === null);
|
||||
if (!ledger) throw new Error("Ledger not found");
|
||||
this.currentLedgerId = ledger.id;
|
||||
await this.persistCurrentLedger();
|
||||
await this.loadCurrentMembers();
|
||||
},
|
||||
async persistCurrentLedger() {
|
||||
if (!this.currentLedgerId) return;
|
||||
const auth = useAuthStore();
|
||||
if (!this.currentLedgerId || !auth.user) return;
|
||||
const preference: UserPreference = {
|
||||
id: currentLedgerPreferenceId,
|
||||
userId: localUserId,
|
||||
id: currentLedgerPreferenceId(auth.user.id),
|
||||
userId: auth.user.id,
|
||||
key: "currentLedgerId",
|
||||
value: this.currentLedgerId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.userPreferences.put(preference);
|
||||
},
|
||||
async loadCurrentMembers() {
|
||||
if (!this.currentLedgerId) {
|
||||
this.currentMembers = [];
|
||||
return;
|
||||
}
|
||||
const result = await apiRequest<{ members: LedgerMember[] }>(
|
||||
`/api/ledgers/${encodeURIComponent(this.currentLedgerId)}/members`,
|
||||
);
|
||||
this.currentMembers = result.members;
|
||||
},
|
||||
memberName(userId: string) {
|
||||
return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户";
|
||||
},
|
||||
async updateLedger(input: Pick<LedgerRecord, "id" | "name" | "color" | "defaultCurrency">) {
|
||||
const existing = await db.ledgers.get(input.id);
|
||||
if (!existing) throw new Error("Ledger not found");
|
||||
const updated: LedgerRecord = {
|
||||
...existing,
|
||||
...input,
|
||||
name: input.name.trim(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.ledgers.put(updated);
|
||||
this.loaded = false;
|
||||
await this.loadLedgers();
|
||||
return updated;
|
||||
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
|
||||
await this.reloadLedgers();
|
||||
return this.ledgers.find((ledger) => ledger.id === input.id)!;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,6 +134,11 @@ input:focus-visible {
|
|||
background: #3478e5;
|
||||
}
|
||||
|
||||
.participant-avatar.member,
|
||||
.share-member-avatar.member {
|
||||
background: #3478e5;
|
||||
}
|
||||
|
||||
.participant-avatar.member-li,
|
||||
.share-member-avatar.member-li {
|
||||
background: #7559d9;
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ async function deleteEntry() {
|
|||
</label>
|
||||
</section>
|
||||
|
||||
<p class="entry-created-at">由小王创建 · {{ createdAtLabel }}</p>
|
||||
<p class="entry-created-at">由{{ ledgerStore.memberName(entry.createdBy) }}创建 · {{ createdAtLabel }}</p>
|
||||
<p v-if="errorMessage" class="entry-save-error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
<script setup lang="ts">
|
||||
import { Check, Clock3, Eye, EyeOff, KeyRound, UserPlus, UserRound } from "@lucide/vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiRequest, ApiError } from "../data/api";
|
||||
import { useAuthStore, type SessionUser } from "../stores/auth";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
type Invitation = {
|
||||
expiresAt: string;
|
||||
status: "valid" | "expired" | "accepted" | "revoked";
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const invitation = ref<Invitation | null>(null);
|
||||
const name = ref("");
|
||||
const password = ref("");
|
||||
const passwordVisible = ref(false);
|
||||
const loading = ref(true);
|
||||
const accepting = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const key = computed(() => typeof route.query.key === "string" ? route.query.key : "");
|
||||
const expiresLabel = computed(() => invitation.value
|
||||
? new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(invitation.value.expiresAt))
|
||||
: "");
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.user) {
|
||||
await router.replace("/");
|
||||
return;
|
||||
}
|
||||
if (!key.value) {
|
||||
errorMessage.value = "邀请链接缺少 key";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await apiRequest<{ invitation?: Invitation; user: SessionUser | null; alreadyLoggedIn?: boolean }>(
|
||||
`/api/invitations/${encodeURIComponent(key.value)}`,
|
||||
);
|
||||
if (result.alreadyLoggedIn || result.user) {
|
||||
await auth.loadSession(true);
|
||||
await router.replace("/");
|
||||
return;
|
||||
}
|
||||
invitation.value = result.invitation ?? null;
|
||||
if (result.invitation?.status !== "valid") {
|
||||
errorMessage.value = {
|
||||
expired: "邀请链接已过期",
|
||||
accepted: "邀请链接已被使用",
|
||||
revoked: "邀请链接已撤销",
|
||||
valid: "",
|
||||
}[result.invitation?.status ?? "revoked"];
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof ApiError ? error.message : "无法读取邀请";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function acceptInvitation() {
|
||||
if (!invitation.value || accepting.value || !name.value.trim() || password.value.length < 8) return;
|
||||
accepting.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await apiRequest(`/api/invitations/${encodeURIComponent(key.value)}/accept`, {
|
||||
method: "POST",
|
||||
body: { name: name.value.trim(), password: password.value },
|
||||
});
|
||||
password.value = "";
|
||||
await auth.loadSession(true);
|
||||
await ledgerStore.reloadLedgers();
|
||||
await router.replace("/");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof ApiError ? error.message : "创建账号失败";
|
||||
} finally {
|
||||
accepting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell invite-shell">
|
||||
<header><span><KeyRound :size="18" /></span><strong>Cents</strong></header>
|
||||
<section class="invite-content">
|
||||
<div v-if="loading" class="invite-state">正在验证邀请...</div>
|
||||
<template v-else-if="invitation">
|
||||
<span class="invite-mark"><UserPlus :size="30" /></span>
|
||||
<div class="invite-heading">
|
||||
<small>你被邀请使用</small>
|
||||
<h1>Cents 家庭账本</h1>
|
||||
<span><Clock3 :size="14" />有效期至 {{ expiresLabel }}</span>
|
||||
</div>
|
||||
|
||||
<form v-if="invitation.status === 'valid'" @submit.prevent="acceptInvitation">
|
||||
<label><span>姓名</span><div><UserRound :size="19" /><input v-model="name" maxlength="40" autocomplete="username" placeholder="也是你的登录名" autofocus /></div></label>
|
||||
<label><span>密码</span><div><KeyRound :size="19" /><input v-model="password" :type="passwordVisible ? 'text' : 'password'" minlength="8" maxlength="128" autocomplete="new-password" placeholder="至少 8 位" /><button type="button" :aria-label="passwordVisible ? '隐藏密码' : '显示密码'" :title="passwordVisible ? '隐藏密码' : '显示密码'" @click="passwordVisible = !passwordVisible"><EyeOff v-if="passwordVisible" :size="19" /><Eye v-else :size="19" /></button></div></label>
|
||||
<p v-if="errorMessage" class="form-error" role="alert">{{ errorMessage }}</p>
|
||||
<button class="invite-submit" type="submit" :disabled="accepting || !name.trim() || password.length < 8"><Check :size="19" />{{ accepting ? "正在创建" : "创建账号" }}</button>
|
||||
</form>
|
||||
<div v-else class="invite-error">{{ errorMessage }}</div>
|
||||
</template>
|
||||
<div v-else class="invite-error">{{ errorMessage }}</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.invite-shell { background:#f4f8f7; }
|
||||
.invite-shell > header { height:64px; display:flex; align-items:center; justify-content:center; gap:8px; border-bottom:1px solid #dce8e5; background:#fff; color:#087f72; }
|
||||
.invite-shell > header span { width:30px; height:30px; display:grid; place-items:center; border-radius:50%; background:#e5f5f1; }
|
||||
.invite-content { height:calc(100% - 64px); display:flex; flex-direction:column; align-items:center; padding:38px 24px 28px; overflow-y:auto; }
|
||||
.invite-mark { width:68px; height:68px; display:grid; place-items:center; border:1px solid #b8dad2; border-radius:50%; background:#fff; color:#087f72; box-shadow:0 12px 30px rgba(34,78,68,.12); }
|
||||
.invite-heading { display:grid; justify-items:center; gap:5px; margin-top:17px; text-align:center; }
|
||||
.invite-heading small { color:#6f7e7a; font-size:13px; }
|
||||
.invite-heading h1 { margin:0; color:#1d302b; font-size:24px; letter-spacing:0; }
|
||||
.invite-heading span { display:flex; align-items:center; gap:5px; color:#71827d; font-size:12px; }
|
||||
.invite-content form { width:100%; display:grid; gap:14px; margin-top:25px; }
|
||||
.invite-content label { display:grid; gap:6px; color:#64746f; font-size:12px; }
|
||||
.invite-content label > div { height:50px; display:grid; grid-template-columns:30px minmax(0,1fr) auto; align-items:center; border:1px solid #cbdad6; border-radius:8px; padding:0 11px; background:#fff; color:#70817c; }
|
||||
.invite-content input { min-width:0; border:0; outline:0; color:#1e312c; font-size:16px; }
|
||||
.invite-content label button { width:38px; height:38px; display:grid; place-items:center; border:0; background:transparent; color:#61756f; }
|
||||
.invite-submit { height:50px; display:flex; align-items:center; justify-content:center; gap:7px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
||||
.invite-submit:disabled { opacity:.5; }
|
||||
.form-error { margin:0; color:#c74d39; font-size:12px; }
|
||||
.invite-state,.invite-error { margin:auto; color:#6e7f7a; font-size:14px; text-align:center; }
|
||||
.invite-error { color:#c74d39; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
import { Check, Clock3, LibraryBig } from "@lucide/vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { apiRequest, ApiError } from "../data/api";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
type LedgerInvitation = {
|
||||
ledgerId: string;
|
||||
ledgerName: string;
|
||||
inviterName: string | null;
|
||||
expiresAt: string;
|
||||
status: "valid" | "expired" | "accepted" | "revoked";
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const invitation = ref<LedgerInvitation | null>(null);
|
||||
const loading = ref(true);
|
||||
const accepting = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const key = computed(() => typeof route.query.key === "string" ? route.query.key : "");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result = await apiRequest<{ invitation: LedgerInvitation }>(`/api/ledger-invitations/${encodeURIComponent(key.value)}`);
|
||||
invitation.value = result.invitation;
|
||||
if (result.invitation.status !== "valid") errorMessage.value = "账本邀请已失效";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof ApiError ? error.message : "无法读取账本邀请";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function accept() {
|
||||
if (!invitation.value || accepting.value) return;
|
||||
accepting.value = true;
|
||||
try {
|
||||
const result = await apiRequest<{ ledgerId: string }>(`/api/ledger-invitations/${encodeURIComponent(key.value)}/accept`, { method: "POST" });
|
||||
await ledgerStore.reloadLedgers();
|
||||
await ledgerStore.setCurrentLedger(result.ledgerId);
|
||||
await router.replace("/");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof ApiError ? error.message : "加入账本失败";
|
||||
} finally {
|
||||
accepting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell join-shell">
|
||||
<section class="join-content">
|
||||
<div v-if="loading" class="join-state">正在读取账本邀请...</div>
|
||||
<template v-else-if="invitation">
|
||||
<span class="join-mark"><LibraryBig :size="31" /></span>
|
||||
<small>{{ invitation.inviterName ? `${invitation.inviterName} 邀请你加入` : "你被邀请加入" }}</small>
|
||||
<h1>{{ invitation.ledgerName }}</h1>
|
||||
<span class="join-expiry"><Clock3 :size="14" />邀请链接 7 天内有效</span>
|
||||
<button v-if="invitation.status === 'valid'" type="button" :disabled="accepting" @click="accept"><Check :size="19" />{{ accepting ? "正在加入" : "加入账本" }}</button>
|
||||
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||
</template>
|
||||
<div v-else class="join-state error">{{ errorMessage }}</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join-shell { display:grid; place-items:center; padding:24px; background:#f4f8f7; }
|
||||
.join-content { width:100%; display:grid; justify-items:center; gap:8px; text-align:center; }
|
||||
.join-mark { width:72px; height:72px; display:grid; place-items:center; margin-bottom:10px; border:1px solid #b8dad2; border-radius:50%; background:#fff; color:#087f72; box-shadow:0 12px 30px rgba(34,78,68,.12); }
|
||||
.join-content small { color:#6f7e7a; font-size:13px; }
|
||||
.join-content h1 { margin:0; color:#1d302b; font-size:25px; letter-spacing:0; }
|
||||
.join-expiry { display:flex; align-items:center; gap:5px; color:#71827d; font-size:12px; }
|
||||
.join-content button { width:100%; height:50px; display:flex; align-items:center; justify-content:center; gap:7px; margin-top:25px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
||||
.join-content button:disabled { opacity:.5; }
|
||||
.join-content p,.join-state.error { color:#c74d39; font-size:12px; }
|
||||
.join-state { color:#6e7f7a; font-size:14px; }
|
||||
</style>
|
||||
|
|
@ -14,6 +14,10 @@ const saving = ref(false);
|
|||
|
||||
onMounted(async () => {
|
||||
await ledgerStore.loadLedgers();
|
||||
if (ledgerStore.currentRole !== "owner") {
|
||||
await router.replace("/");
|
||||
return;
|
||||
}
|
||||
if (!ledgerStore.currentLedger) return;
|
||||
name.value = ledgerStore.currentLedger.name;
|
||||
color.value = ledgerStore.currentLedger.color;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { apiRequest, ApiError } from "../data/api";
|
||||
import { findCategory, findCategoryPath } from "../data/categories";
|
||||
import { useEntryStore } from "../stores/entries";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
|
@ -27,6 +28,8 @@ const moreMenuOpen = ref(false);
|
|||
const participantOpen = ref(false);
|
||||
const shareOpen = ref(false);
|
||||
const shareFeedback = ref("");
|
||||
const invitationLink = ref("");
|
||||
const generatingInvitation = ref(false);
|
||||
const visibleCount = ref(ENTRY_PAGE_SIZE);
|
||||
const ledgerContent = ref<HTMLElement | null>(null);
|
||||
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
||||
|
|
@ -155,12 +158,14 @@ function categoryLabel(entry: LedgerEntry) {
|
|||
|
||||
function openParticipantSheet() {
|
||||
participantOpen.value = true;
|
||||
void ledgerStore.loadCurrentMembers();
|
||||
}
|
||||
|
||||
function openShareSheet() {
|
||||
async function openShareSheet() {
|
||||
moreMenuOpen.value = false;
|
||||
shareFeedback.value = "";
|
||||
shareFeedback.value = "正在生成邀请链接";
|
||||
shareOpen.value = true;
|
||||
await ensureInvitation();
|
||||
}
|
||||
|
||||
function openLedgerSettings() {
|
||||
|
|
@ -168,14 +173,27 @@ function openLedgerSettings() {
|
|||
router.push("/ledger/settings");
|
||||
}
|
||||
|
||||
function invitationUrl() {
|
||||
const url = new URL(window.location.origin);
|
||||
url.searchParams.set("invite", ledgerStore.currentLedgerId);
|
||||
return url.toString();
|
||||
async function ensureInvitation() {
|
||||
if (invitationLink.value || generatingInvitation.value || !ledgerStore.currentLedgerId) return invitationLink.value;
|
||||
generatingInvitation.value = true;
|
||||
try {
|
||||
const result = await apiRequest<{ invitation: { url: string; expiresAt: string } }>(
|
||||
`/api/ledgers/${encodeURIComponent(ledgerStore.currentLedgerId)}/invitations`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
invitationLink.value = result.invitation.url;
|
||||
shareFeedback.value = "邀请链接将在 7 天后失效";
|
||||
} catch (error) {
|
||||
shareFeedback.value = error instanceof ApiError ? error.message : "邀请链接生成失败";
|
||||
} finally {
|
||||
generatingInvitation.value = false;
|
||||
}
|
||||
return invitationLink.value;
|
||||
}
|
||||
|
||||
async function copyInvitation() {
|
||||
const link = invitationUrl();
|
||||
const link = await ensureInvitation();
|
||||
if (!link) return;
|
||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link);
|
||||
else {
|
||||
const input = document.createElement("textarea");
|
||||
|
|
@ -189,10 +207,12 @@ async function copyInvitation() {
|
|||
}
|
||||
|
||||
async function shareLedger() {
|
||||
const url = await ensureInvitation();
|
||||
if (!url) return;
|
||||
const data = {
|
||||
title: ledgerStore.currentLedger?.name ?? "账本",
|
||||
text: `邀请你加入我的${ledgerStore.currentLedger?.name ?? "账本"}`,
|
||||
url: invitationUrl(),
|
||||
url,
|
||||
};
|
||||
if (!navigator.share) {
|
||||
await copyInvitation();
|
||||
|
|
@ -214,15 +234,15 @@ async function shareLedger() {
|
|||
<header class="ledger-header">
|
||||
<div class="top-actions">
|
||||
<button class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
|
||||
<span class="participant-avatar owner">我</span>
|
||||
<span class="participant-avatar member-wang">王</span>
|
||||
<span class="participant-avatar member-li">李</span>
|
||||
<span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">
|
||||
{{ member.name.slice(0, 1) }}
|
||||
</span>
|
||||
</button>
|
||||
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
|
||||
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
|
||||
<ChevronDown :size="16" />
|
||||
</button>
|
||||
<button class="icon-button header-icon" type="button" aria-label="更多" title="更多" @click.stop="moreMenuOpen = true">
|
||||
<button v-if="ledgerStore.currentRole === 'owner'" class="icon-button header-icon" type="button" aria-label="更多" title="更多" @click.stop="moreMenuOpen = true">
|
||||
<MoreHorizontal :size="21" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -296,7 +316,7 @@ async function shareLedger() {
|
|||
<div class="entry-copy">
|
||||
<strong>{{ entry.note || categoryLabel(entry) }}</strong>
|
||||
<div class="entry-subline">
|
||||
<span class="entry-recorder"><CircleUserRound :size="12" />小王</span>
|
||||
<span class="entry-recorder"><CircleUserRound :size="12" />{{ ledgerStore.memberName(entry.createdBy) }}</span>
|
||||
<span class="entry-category-label">{{ categoryLabel(entry) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -329,23 +349,15 @@ async function shareLedger() {
|
|||
<header>
|
||||
<div>
|
||||
<strong>{{ ledgerStore.currentLedger?.name ?? "账本" }}</strong>
|
||||
<span>3 位参与者</span>
|
||||
<span>{{ ledgerStore.currentMembers.length }} 位参与者</span>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭" title="关闭" @click="participantOpen = false"><X :size="20" /></button>
|
||||
</header>
|
||||
|
||||
<div class="share-member-list" aria-label="当前参与者">
|
||||
<div class="share-member-row">
|
||||
<span class="share-member-avatar owner">我</span>
|
||||
<span><strong>自己</strong><small>拥有者</small></span>
|
||||
</div>
|
||||
<div class="share-member-row">
|
||||
<span class="share-member-avatar member-wang">王</span>
|
||||
<span><strong>小王</strong><small>成员</small></span>
|
||||
</div>
|
||||
<div class="share-member-row">
|
||||
<span class="share-member-avatar member-li">李</span>
|
||||
<span><strong>小李</strong><small>成员</small></span>
|
||||
<div v-for="member in ledgerStore.currentMembers" :key="member.id" class="share-member-row">
|
||||
<span class="share-member-avatar" :class="member.role">{{ member.name.slice(0, 1) }}</span>
|
||||
<span><strong>{{ member.name }}</strong><small>{{ member.role === "owner" ? "拥有者" : "成员" }}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -366,8 +378,8 @@ async function shareLedger() {
|
|||
<button type="button" aria-label="关闭" title="关闭" @click="shareOpen = false"><X :size="20" /></button>
|
||||
</header>
|
||||
<div class="share-actions">
|
||||
<button type="button" class="copy-invite-action" @click="copyInvitation"><Copy :size="18" />复制链接</button>
|
||||
<button type="button" class="system-share-action" @click="shareLedger"><Share2 :size="18" />分享账本</button>
|
||||
<button type="button" class="copy-invite-action" :disabled="generatingInvitation" @click="copyInvitation"><Copy :size="18" />复制链接</button>
|
||||
<button type="button" class="system-share-action" :disabled="generatingInvitation" @click="shareLedger"><Share2 :size="18" />分享账本</button>
|
||||
</div>
|
||||
<p class="share-feedback" role="status">{{ shareFeedback || "通过邀请链接加入此账本" }}</p>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { Eye, EyeOff, KeyRound, LogIn, UserRound } from "@lucide/vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ApiError } from "../data/api";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const name = ref("");
|
||||
const password = ref("");
|
||||
const passwordVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const redirect = computed(() => typeof route.query.redirect === "string" && route.query.redirect.startsWith("/")
|
||||
? route.query.redirect
|
||||
: "/");
|
||||
|
||||
async function login() {
|
||||
if (!name.value.trim() || !password.value || submitting.value) return;
|
||||
submitting.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await auth.login(name.value.trim(), password.value);
|
||||
await ledgerStore.reloadLedgers();
|
||||
await router.replace(redirect.value);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof ApiError ? error.message : "暂时无法登录";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell login-shell">
|
||||
<header><span><KeyRound :size="18" /></span><strong>Cents</strong></header>
|
||||
<section class="login-content">
|
||||
<div class="login-heading"><h1>登录</h1><p>继续使用家庭账本</p></div>
|
||||
<form @submit.prevent="login">
|
||||
<label><span>姓名</span><div><UserRound :size="19" /><input v-model="name" maxlength="40" autocomplete="username" /></div></label>
|
||||
<label><span>密码</span><div><KeyRound :size="19" /><input v-model="password" :type="passwordVisible ? 'text' : 'password'" maxlength="128" autocomplete="current-password" /><button type="button" :aria-label="passwordVisible ? '隐藏密码' : '显示密码'" :title="passwordVisible ? '隐藏密码' : '显示密码'" @click="passwordVisible = !passwordVisible"><EyeOff v-if="passwordVisible" :size="19" /><Eye v-else :size="19" /></button></div></label>
|
||||
<p v-if="errorMessage" role="alert">{{ errorMessage }}</p>
|
||||
<button class="login-submit" type="submit" :disabled="submitting || !name.trim() || !password"><LogIn :size="19" />{{ submitting ? "正在登录" : "登录" }}</button>
|
||||
</form>
|
||||
<small>没有账号时,需要先打开应用邀请链接</small>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-shell { background:#f4f8f7; }
|
||||
.login-shell > header { height:64px; display:flex; align-items:center; justify-content:center; gap:8px; border-bottom:1px solid #dce8e5; background:#fff; color:#087f72; }
|
||||
.login-shell > header span { width:30px; height:30px; display:grid; place-items:center; border-radius:50%; background:#e5f5f1; }
|
||||
.login-content { height:calc(100% - 64px); display:flex; flex-direction:column; padding:58px 24px 32px; overflow-y:auto; }
|
||||
.login-heading { display:grid; gap:5px; }
|
||||
.login-heading h1 { margin:0; color:#1d302b; font-size:27px; letter-spacing:0; }
|
||||
.login-heading p { margin:0; color:#71807c; font-size:14px; }
|
||||
.login-content form { display:grid; gap:16px; margin-top:30px; }
|
||||
.login-content label { display:grid; gap:7px; color:#64746f; font-size:12px; }
|
||||
.login-content label > div { height:52px; display:grid; grid-template-columns:30px minmax(0,1fr) auto; align-items:center; border:1px solid #cbdad6; border-radius:8px; padding:0 11px; background:#fff; color:#70817c; }
|
||||
.login-content input { min-width:0; border:0; outline:0; color:#1e312c; font-size:16px; }
|
||||
.login-content label button { width:38px; height:38px; display:grid; place-items:center; border:0; background:transparent; color:#61756f; }
|
||||
.login-content form > p { margin:0; color:#c74d39; font-size:12px; }
|
||||
.login-submit { height:52px; display:flex; align-items:center; justify-content:center; gap:7px; border:0; border-radius:8px; background:#087f72; color:#fff; font-weight:720; }
|
||||
.login-submit:disabled { opacity:.5; }
|
||||
.login-content > small { margin-top:18px; color:#7a8985; font-size:11px; text-align:center; }
|
||||
</style>
|
||||
|
|
@ -1,13 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { Bell, ChevronRight, CircleUserRound, Cloud, ShieldCheck } from "@lucide/vue";
|
||||
import { Bell, Check, ChevronRight, CircleUserRound, Cloud, Pencil, ShieldCheck, X } from "@lucide/vue";
|
||||
import { ref } from "vue";
|
||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useLedgerStore } from "../stores/ledgers";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const ledgerStore = useLedgerStore();
|
||||
const editing = ref(false);
|
||||
const name = ref("");
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
function startEditing() {
|
||||
name.value = auth.user?.name ?? "";
|
||||
errorMessage.value = "";
|
||||
editing.value = true;
|
||||
}
|
||||
|
||||
async function saveName() {
|
||||
if (!name.value.trim() || saving.value) return;
|
||||
saving.value = true;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await auth.updateName(name.value.trim());
|
||||
await ledgerStore.loadCurrentMembers();
|
||||
editing.value = false;
|
||||
} catch {
|
||||
errorMessage.value = "姓名保存失败";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="app-shell me-shell">
|
||||
<header class="me-header"><strong>我的</strong></header>
|
||||
<div class="me-scroll">
|
||||
<section class="profile-band"><span><CircleUserRound :size="32" /></span><div><strong>自己</strong><small>本地用户</small></div></section>
|
||||
<section class="profile-band">
|
||||
<span><CircleUserRound :size="32" /></span>
|
||||
<form v-if="editing" @submit.prevent="saveName">
|
||||
<input v-model="name" maxlength="40" aria-label="姓名" autocomplete="name" autofocus />
|
||||
<button type="button" aria-label="取消修改" title="取消" @click="editing = false"><X :size="18" /></button>
|
||||
<button type="submit" aria-label="保存姓名" title="保存" :disabled="saving || !name.trim()"><Check :size="18" /></button>
|
||||
<small v-if="errorMessage">{{ errorMessage }}</small>
|
||||
</form>
|
||||
<div v-else><strong>{{ auth.user?.name }}</strong><small>成员 ID · {{ auth.user?.id.slice(0, 8) }}</small></div>
|
||||
<button v-if="!editing" class="edit-name" type="button" aria-label="修改姓名" title="修改姓名" @click="startEditing"><Pencil :size="18" /></button>
|
||||
</section>
|
||||
<section class="me-settings">
|
||||
<button type="button"><Bell :size="19" /><span>通知与提醒</span><ChevronRight :size="18" /></button>
|
||||
<button type="button"><Cloud :size="19" /><span>同步状态</span><ChevronRight :size="18" /></button>
|
||||
|
|
@ -23,14 +64,20 @@ import QuickEntryHost from "../components/QuickEntryHost.vue";
|
|||
.me-header { height:70px; display:flex; align-items:end; border-bottom:1px solid #dce7e4; padding:14px 18px 12px; background:#fff; }
|
||||
.me-header strong { font-size:20px; }
|
||||
.me-scroll { height:calc(100% - 70px); overflow-y:auto; padding:14px 16px 104px; }
|
||||
.profile-band { min-height:94px; display:grid; grid-template-columns:58px 1fr; align-items:center; gap:12px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; padding:12px 14px; background:#fff; }
|
||||
.profile-band { position:relative; min-height:94px; display:grid; grid-template-columns:58px minmax(0,1fr) 40px; align-items:center; gap:12px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; padding:12px 14px; background:#fff; }
|
||||
.profile-band > span { width:54px; height:54px; display:grid; place-items:center; border-radius:50%; background:#e8f7f3; color:#087f72; }
|
||||
.profile-band > div { display:grid; gap:2px; }
|
||||
.profile-band strong { font-size:16px; }
|
||||
.profile-band > div { min-width:0; display:grid; gap:2px; }
|
||||
.profile-band strong { overflow:hidden; font-size:16px; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.profile-band small { color:#7b8884; font-size:11px; }
|
||||
.edit-name,.profile-band form button { width:38px; height:38px; display:grid; place-items:center; border:0; border-radius:50%; background:transparent; color:#536963; }
|
||||
.profile-band form { display:grid; grid-template-columns:minmax(0,1fr) 38px 38px; align-items:center; gap:2px; }
|
||||
.profile-band form input { min-width:0; height:42px; border:0; border-bottom:2px solid #087f72; outline:0; color:#26342f; font-size:16px; }
|
||||
.profile-band form button:last-of-type { background:#e7f6f2; color:#087f72; }
|
||||
.profile-band form button:disabled { opacity:.45; }
|
||||
.profile-band form small { grid-column:1/-1; color:#cf513d; }
|
||||
.me-settings { margin-top:12px; border-top:1px solid #dce7e4; border-bottom:1px solid #dce7e4; padding:0 14px; background:#fff; }
|
||||
.me-settings button { width:100%; min-height:58px; display:grid; grid-template-columns:28px 1fr 20px; align-items:center; gap:8px; border:0; border-bottom:1px solid #e5edeb; padding:0; background:#fff; color:#31504a; text-align:left; }
|
||||
.me-settings button:last-child { border-bottom:0; }
|
||||
.me-settings span { color:#26342f; font-size:14px; }
|
||||
@media (max-width:360px) { .me-scroll { padding-right:12px; padding-left:12px; } }
|
||||
@media (max-width:360px) { .me-scroll { padding-right:12px; padding-left:12px; } .profile-band { grid-template-columns:52px minmax(0,1fr) 38px; gap:8px; padding-right:10px; padding-left:10px; } }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,17 @@ const categoryStats = computed(() => {
|
|||
|
||||
const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map((item) => item.amount)));
|
||||
|
||||
const memberStats = computed(() => {
|
||||
const groups = new Map<string, LedgerEntry[]>();
|
||||
for (const entry of entries.value) groups.set(entry.createdBy, [...(groups.get(entry.createdBy) ?? []), entry]);
|
||||
return Array.from(groups, ([userId, items]) => ({
|
||||
userId,
|
||||
name: ledgerStore.memberName(userId),
|
||||
count: items.length,
|
||||
...summarize(items),
|
||||
})).sort((left, right) => right.count - left.count);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
|
||||
});
|
||||
|
|
@ -141,11 +152,11 @@ function money(value: number) {
|
|||
</section>
|
||||
|
||||
<section v-else class="stats-section" aria-label="记录者统计">
|
||||
<header><strong>记录者</strong><span>1 人</span></header>
|
||||
<div class="member-stat-row">
|
||||
<header><strong>记录者</strong><span>{{ memberStats.length }} 人</span></header>
|
||||
<div v-for="member in memberStats" :key="member.userId" class="member-stat-row">
|
||||
<span><CircleUserRound :size="22" /></span>
|
||||
<div><strong>小王</strong><small>{{ entries.length }} 笔记录</small></div>
|
||||
<div><small class="income">收入 {{ money(totals.income) }}</small><small class="expense">支出 {{ money(totals.expense) }}</small></div>
|
||||
<div><strong>{{ member.name }}</strong><small>{{ member.count }} 笔记录</small></div>
|
||||
<div><small class="income">收入 {{ money(member.income) }}</small><small class="expense">支出 {{ money(member.expense) }}</small></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,77 @@
|
|||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
const monorepoRoot = new URL("../..", import.meta.url).pathname;
|
||||
|
||||
type AppVariant = "dev" | "pro";
|
||||
|
||||
function appIdentityPlugin(mode: string): Plugin {
|
||||
const env = loadEnv(mode, monorepoRoot, "");
|
||||
const defaultVariant: AppVariant = mode === "development" ? "dev" : "pro";
|
||||
const variant: AppVariant = env.APP_VARIANT === "dev" || env.APP_VARIANT === "pro" ? env.APP_VARIANT : defaultVariant;
|
||||
const appName = env.VITE_APP_NAME || (variant === "dev" ? "Cents Dev" : "Cents");
|
||||
const shortName = env.VITE_APP_SHORT_NAME || appName;
|
||||
const appId = env.VITE_APP_ID || (variant === "dev" ? "/?app=cents-dev" : "/?app=cents");
|
||||
|
||||
const manifest = JSON.stringify(
|
||||
{
|
||||
name: appName,
|
||||
short_name: shortName,
|
||||
id: appId,
|
||||
description: "家庭共享账本",
|
||||
start_url: "/",
|
||||
display: "standalone",
|
||||
background_color: "#f7f4ee",
|
||||
theme_color: "#f7f4ee",
|
||||
icons: [
|
||||
{
|
||||
src: "/icon.svg",
|
||||
sizes: "any",
|
||||
type: "image/svg+xml",
|
||||
purpose: "any maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
return {
|
||||
name: "cents-app-identity",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.url?.split("?")[0] !== "/manifest.webmanifest") {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", "application/manifest+json; charset=utf-8");
|
||||
res.end(manifest);
|
||||
});
|
||||
},
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(/<title>.*<\/title>/, `<title>${appName}</title>`);
|
||||
},
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "manifest.webmanifest",
|
||||
source: manifest,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
envDir: monorepoRoot,
|
||||
plugins: [vue(), appIdentityPlugin(mode)],
|
||||
server: {
|
||||
allowedHosts: ["dev.cents.homemade.net.cn"],
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3000",
|
||||
changeOrigin: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit f5d59a3f9222285eb9c179bd33574099a4325ec0
|
||||
|
|
@ -1,4 +1,21 @@
|
|||
services:
|
||||
db:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: cents
|
||||
POSTGRES_USER: cents
|
||||
POSTGRES_PASSWORD: cents-dev-only
|
||||
ports:
|
||||
- "127.0.0.1:55432:5432"
|
||||
volumes:
|
||||
- cents_dev_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U cents -d cents"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
|
||||
frpc:
|
||||
image: homemade/auto-cert/proxy:latest
|
||||
restart: unless-stopped
|
||||
|
|
@ -26,9 +43,9 @@ services:
|
|||
- frp_dev
|
||||
|
||||
volumes:
|
||||
cents_dev_db:
|
||||
frp_cert_volume:
|
||||
|
||||
networks:
|
||||
frp_dev:
|
||||
driver: bridge
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@
|
|||
|
||||
### 账号与访问
|
||||
|
||||
- 不开放自助注册。
|
||||
- 后台分配用户名密码。
|
||||
- 设备 ID 审批作为备选方案。
|
||||
- 不开放自助注册,只能通过 7 天有效的单次应用邀请建立账号。
|
||||
- 使用姓名和密码登录;姓名必须唯一,密码由管理员在后台重置。
|
||||
- 用户只能访问自己有权限的账本。
|
||||
|
||||
### 账本
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
# 账号与权限
|
||||
|
||||
## 账号创建
|
||||
|
||||
Cents 不开放注册。管理员生成一条应用邀请:
|
||||
|
||||
```bash
|
||||
PUBLIC_ORIGIN=https://cents.example.com npm run bootstrap --workspace @cents/api
|
||||
```
|
||||
|
||||
应用邀请使用 256 位随机 `key`,数据库只保存 SHA-256 摘要。链接固定 7 天过期并且只能创建一个账号。
|
||||
|
||||
新用户打开 `/invite?key=...` 后填写姓名和密码:
|
||||
|
||||
- 用户 ID 由服务端生成 UUID。
|
||||
- 姓名同时作为登录名,忽略首尾空格和大小写后必须唯一。
|
||||
- 密码长度为 8 至 128 个字符。
|
||||
- 密码使用 Argon2id 保存,参数为 19 MiB 内存、2 次迭代、并行度 1。
|
||||
- 新账号自动获得一个自己的默认账本,并成为 owner。
|
||||
|
||||
已登录用户打开应用邀请时直接返回应用,不创建账号,也不消费邀请。
|
||||
|
||||
## 登录会话
|
||||
|
||||
登录成功后,服务端生成 256 位随机会话凭证,并通过 Cookie 返回:
|
||||
|
||||
- `HttpOnly`:前端脚本不能读取。
|
||||
- `Secure`:生产环境只允许 HTTPS 发送。
|
||||
- `SameSite=Lax`:限制跨站携带。
|
||||
- `Path=/`:不设置 `Domain`。
|
||||
|
||||
数据库只保存会话凭证的 SHA-256 摘要。浏览器不保存原始密码。会话最长设置为浏览器普遍支持的 400 天,并在每次有效访问时滚动续期,因此正常使用时会一直保持登录。
|
||||
|
||||
登录接口使用统一的“姓名或密码错误”提示,并设置更严格的请求限速。
|
||||
|
||||
## 密码重置
|
||||
|
||||
不提供公网“忘记密码”接口。管理员登录宿主机后执行:
|
||||
|
||||
```bash
|
||||
NEW_PASSWORD='新的密码' npm run password:reset --workspace @cents/api -- '用户姓名'
|
||||
```
|
||||
|
||||
重置成功后,该用户的所有旧会话会立即撤销,需要使用新密码重新登录。
|
||||
|
||||
## 邀请类型
|
||||
|
||||
应用邀请与账本邀请是两个不同的能力:
|
||||
|
||||
- `/invite?key=...`:创建应用账号,不加入别人的账本。
|
||||
- `/join-ledger?key=...`:把已经登录的用户加入指定账本。
|
||||
|
||||
未登录用户打开账本邀请会先进入登录页,登录成功后返回原邀请继续操作。
|
||||
|
||||
## 权限
|
||||
|
||||
- 用户只能访问自己是成员的账本。
|
||||
- `owner` 可以修改账本设置并创建账本邀请。
|
||||
- `member` 可以查看和记账,但不能创建邀请或修改账本设置。
|
||||
- 服务端在每次请求时查询成员关系,不依赖前端隐藏按钮。
|
||||
- 所有写请求检查 `Origin`,API 同时启用安全响应头和请求限速。
|
||||
|
|
@ -92,6 +92,37 @@
|
|||
"resolved": "apps/web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
|
|
@ -529,6 +560,39 @@
|
|||
"fast-uri": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.2.tgz",
|
||||
"integrity": "sha512-Dtrpk/YOGUsbRMvP/8ZqPpwnMRv0qSqodFdoQ2B589Obc7jw4s4Qla+cV72Bsm7WsZJnqlYFX/i7uSBq0xzg6g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^2.0.0",
|
||||
"fastify-plugin": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie/node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/error": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||
|
|
@ -580,6 +644,26 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/helmet": {
|
||||
"version": "13.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/helmet/-/helmet-13.1.0.tgz",
|
||||
"integrity": "sha512-SvVOU0IrzYJW1BvSkfq9G1WUdW3dnaRUvg6m0BtgGMBmML62No0VmSu087jecH58SFbicbREgZTPJ89mAguupA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"helmet": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/merge-json-schemas": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
||||
|
|
@ -619,6 +703,27 @@
|
|||
"ipaddr.js": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/rate-limit": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.1.0.tgz",
|
||||
"integrity": "sha512-BeJ9tizLvmTXGD7deYU5G04OtHhwk5uHxbpEPVp09gKvUBIXmau/4Bshxhu9ci54MvVWfGjCEx4RzvsTntojwA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lukeed/ms": "^2.0.2",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
|
|
@ -634,6 +739,276 @@
|
|||
"vue": ">=3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@lukeed/ms": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
|
||||
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.0.2.tgz",
|
||||
"integrity": "sha512-t64wIsPEtNd4aUPuTAyeL2ubxATCBGmeluaKXEMAFk/8w6AJIVVkeLKMBpgLW6LU2t5cQxT+env/c6jxbtTQBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@node-rs/argon2-android-arm-eabi": "2.0.2",
|
||||
"@node-rs/argon2-android-arm64": "2.0.2",
|
||||
"@node-rs/argon2-darwin-arm64": "2.0.2",
|
||||
"@node-rs/argon2-darwin-x64": "2.0.2",
|
||||
"@node-rs/argon2-freebsd-x64": "2.0.2",
|
||||
"@node-rs/argon2-linux-arm-gnueabihf": "2.0.2",
|
||||
"@node-rs/argon2-linux-arm64-gnu": "2.0.2",
|
||||
"@node-rs/argon2-linux-arm64-musl": "2.0.2",
|
||||
"@node-rs/argon2-linux-x64-gnu": "2.0.2",
|
||||
"@node-rs/argon2-linux-x64-musl": "2.0.2",
|
||||
"@node-rs/argon2-wasm32-wasi": "2.0.2",
|
||||
"@node-rs/argon2-win32-arm64-msvc": "2.0.2",
|
||||
"@node-rs/argon2-win32-ia32-msvc": "2.0.2",
|
||||
"@node-rs/argon2-win32-x64-msvc": "2.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-android-arm-eabi": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz",
|
||||
"integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-android-arm64": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz",
|
||||
"integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz",
|
||||
"integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-darwin-x64": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz",
|
||||
"integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-freebsd-x64": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz",
|
||||
"integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz",
|
||||
"integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz",
|
||||
"integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm64-musl": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz",
|
||||
"integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-x64-gnu": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz",
|
||||
"integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-x64-musl": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz",
|
||||
"integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-wasm32-wasi": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz",
|
||||
"integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^0.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz",
|
||||
"integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz",
|
||||
"integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-x64-msvc": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz",
|
||||
"integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
|
|
@ -971,6 +1346,16 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
|
|
@ -987,6 +1372,18 @@
|
|||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
|
||||
|
|
@ -1481,6 +1878,22 @@
|
|||
"toad-cache": "^3.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastify-plugin": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
|
||||
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
|
|
@ -1535,6 +1948,18 @@
|
|||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/EvanHahn"
|
||||
}
|
||||
},
|
||||
"node_modules/hookable": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||
|
|
@ -1686,6 +2111,95 @@
|
|||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.22.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.14.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.15.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -1790,6 +2304,45 @@
|
|||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
|
|
@ -2055,6 +2608,13 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
|
|
@ -2235,6 +2795,15 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"packages/domain": {
|
||||
"name": "@cents/domain",
|
||||
"version": "0.1.0",
|
||||
|
|
@ -2247,10 +2816,16 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@cents/domain": "0.1.0",
|
||||
"fastify": "^5.4.0"
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/helmet": "^13.1.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"fastify": "^5.4.0",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,23 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"bootstrap": "tsx src/bootstrap.ts",
|
||||
"password:reset": "tsx src/reset-password.ts",
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cents/domain": "0.1.0",
|
||||
"fastify": "^5.4.0"
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/helmet": "^13.1.0",
|
||||
"@fastify/rate-limit": "^11.1.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"fastify": "^5.4.0",
|
||||
"pg": "^8.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { initializeDatabase, pool } from "./db.js";
|
||||
import { createId, createSecret, hashSecret, INVITATION_TTL_MS } from "./security.js";
|
||||
|
||||
await initializeDatabase();
|
||||
|
||||
const key = createSecret();
|
||||
const expiresAt = new Date(Date.now() + INVITATION_TTL_MS);
|
||||
await pool.query(
|
||||
`INSERT INTO app_invitations (id, key_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[createId(), hashSecret(key), expiresAt],
|
||||
);
|
||||
|
||||
const origin = process.env.PUBLIC_ORIGIN ?? "http://localhost:5173";
|
||||
process.stdout.write(`${origin}/invite?key=${key}\n`);
|
||||
await pool.end();
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL ?? "postgres://cents:cents-dev-only@127.0.0.1:55432/cents",
|
||||
max: 10,
|
||||
});
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id text PRIMARY KEY,
|
||||
name varchar(40) NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 40),
|
||||
name_key varchar(40),
|
||||
password_hash text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS name_key varchar(40);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash text;
|
||||
UPDATE users SET name_key = lower(trim(name)) WHERE name_key IS NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_name_key_unique ON users (name_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledgers (
|
||||
id text PRIMARY KEY,
|
||||
name varchar(40) NOT NULL,
|
||||
color varchar(16) NOT NULL DEFAULT '#087f72',
|
||||
default_currency varchar(3) NOT NULL DEFAULT 'CNY',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
archived_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_members (
|
||||
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role varchar(8) NOT NULL CHECK (role IN ('owner', 'member')),
|
||||
joined_at timestamptz NOT NULL DEFAULT now(),
|
||||
removed_at timestamptz,
|
||||
PRIMARY KEY (ledger_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ledger_invitations (
|
||||
id text PRIMARY KEY,
|
||||
ledger_id text NOT NULL REFERENCES ledgers(id) ON DELETE CASCADE,
|
||||
key_hash char(64) NOT NULL UNIQUE,
|
||||
role varchar(8) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member')),
|
||||
created_by text REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_by text REFERENCES users(id) ON DELETE SET NULL,
|
||||
accepted_at timestamptz,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_invitations (
|
||||
id text PRIMARY KEY,
|
||||
key_hash char(64) NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
accepted_by text REFERENCES users(id) ON DELETE SET NULL,
|
||||
accepted_at timestamptz,
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS app_invitations_lookup
|
||||
ON app_invitations (key_hash, expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ledger_invitations_lookup
|
||||
ON ledger_invitations (key_hash, expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id text PRIMARY KEY,
|
||||
user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash char(64) NOT NULL UNIQUE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL,
|
||||
last_seen_at timestamptz NOT NULL DEFAULT now(),
|
||||
revoked_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS sessions_lookup ON sessions (token_hash, expires_at);
|
||||
`;
|
||||
|
||||
export async function initializeDatabase() {
|
||||
await pool.query(schema);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { initializeDatabase, pool } from "./db.js";
|
||||
import { hashPassword, normalizeName, validPassword } from "./security.js";
|
||||
|
||||
const name = process.argv[2]?.trim() ?? "";
|
||||
const password = process.env.NEW_PASSWORD ?? "";
|
||||
if (!name || !validPassword(password)) {
|
||||
throw new Error("用法:NEW_PASSWORD='至少8位的新密码' npm run password:reset --workspace @cents/api -- '姓名'");
|
||||
}
|
||||
|
||||
await initializeDatabase();
|
||||
const passwordHash = await hashPassword(password);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await client.query<{ id: string }>(
|
||||
"UPDATE users SET password_hash = $1, updated_at = now() WHERE name_key = $2 RETURNING id",
|
||||
[passwordHash, normalizeName(name)],
|
||||
);
|
||||
const user = result.rows[0];
|
||||
if (!user) throw new Error("未找到该用户");
|
||||
await client.query("UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", [user.id]);
|
||||
await client.query("COMMIT");
|
||||
process.stdout.write("密码已重置,旧登录会话已撤销。\n");
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import type { FastifyReply } from "fastify";
|
||||
|
||||
export const INVITATION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const SESSION_TTL_MS = 400 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const sessionCookieName = process.env.COOKIE_SECURE === "true"
|
||||
? "__Host-cents_session"
|
||||
: "cents_session";
|
||||
|
||||
export function createSecret() {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function hashSecret(secret: string) {
|
||||
return createHash("sha256").update(secret).digest("hex");
|
||||
}
|
||||
|
||||
export function createId() {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
export function normalizeName(name: string) {
|
||||
return name.trim().toLocaleLowerCase("zh-CN");
|
||||
}
|
||||
|
||||
export function validPassword(password: string) {
|
||||
return password.length >= 8 && password.length <= 128;
|
||||
}
|
||||
|
||||
export function hashPassword(password: string) {
|
||||
return hash(password, {
|
||||
memoryCost: 19_456,
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
outputLen: 32,
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyPassword(passwordHash: string, password: string) {
|
||||
return verify(passwordHash, password);
|
||||
}
|
||||
|
||||
export function setSessionCookie(reply: FastifyReply, token: string) {
|
||||
reply.setCookie(sessionCookieName, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
secure: process.env.COOKIE_SECURE === "true",
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_TTL_MS / 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSessionCookie(reply: FastifyReply) {
|
||||
reply.clearCookie(sessionCookieName, {
|
||||
path: "/",
|
||||
secure: process.env.COOKIE_SECURE === "true",
|
||||
sameSite: "lax",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,25 +1,373 @@
|
|||
import Fastify from "fastify";
|
||||
import cookie from "@fastify/cookie";
|
||||
import helmet from "@fastify/helmet";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
||||
import { initializeDatabase, pool } from "./db.js";
|
||||
import {
|
||||
clearSessionCookie,
|
||||
createId,
|
||||
createSecret,
|
||||
hashPassword,
|
||||
hashSecret,
|
||||
INVITATION_TTL_MS,
|
||||
normalizeName,
|
||||
SESSION_TTL_MS,
|
||||
sessionCookieName,
|
||||
setSessionCookie,
|
||||
validPassword,
|
||||
verifyPassword,
|
||||
} from "./security.js";
|
||||
|
||||
const server = Fastify({
|
||||
logger: true,
|
||||
type User = { id: string; name: string };
|
||||
type MemberRole = "owner" | "member";
|
||||
|
||||
const server = Fastify({ logger: true, trustProxy: true });
|
||||
await server.register(cookie);
|
||||
await server.register(helmet, { contentSecurityPolicy: false });
|
||||
await server.register(rateLimit, { max: 120, timeWindow: "1 minute" });
|
||||
await initializeDatabase();
|
||||
|
||||
server.addHook("onRequest", async (request, reply) => {
|
||||
if (!["POST", "PATCH", "PUT", "DELETE"].includes(request.method)) return;
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) return;
|
||||
try {
|
||||
const configuredOrigin = process.env.PUBLIC_ORIGIN ? new URL(process.env.PUBLIC_ORIGIN).origin : null;
|
||||
if (new URL(origin).host !== request.headers.host && new URL(origin).origin !== configuredOrigin) {
|
||||
return reply.code(403).send({ error: "不允许跨站提交" });
|
||||
}
|
||||
} catch {
|
||||
return reply.code(403).send({ error: "请求来源无效" });
|
||||
}
|
||||
});
|
||||
|
||||
server.get("/health", async () => ({
|
||||
ok: true,
|
||||
service: "cents-api",
|
||||
}));
|
||||
server.addHook("onSend", async (request, reply, payload) => {
|
||||
if (
|
||||
request.url.startsWith("/api/auth/")
|
||||
|| request.url.startsWith("/api/invitations/")
|
||||
|| request.url.startsWith("/api/ledger-invitations/")
|
||||
|| request.url === "/api/me"
|
||||
) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
}
|
||||
return payload;
|
||||
});
|
||||
|
||||
server.post("/sync/push", async () => ({
|
||||
accepted: true,
|
||||
operations: [],
|
||||
}));
|
||||
function uniqueViolation(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === "23505";
|
||||
}
|
||||
|
||||
server.get("/sync/pull", async () => ({
|
||||
cursor: null,
|
||||
operations: [],
|
||||
}));
|
||||
async function currentUser(request: FastifyRequest, reply?: FastifyReply): Promise<User | null> {
|
||||
const token = request.cookies[sessionCookieName];
|
||||
if (!token) return null;
|
||||
const tokenHash = hashSecret(token);
|
||||
const result = await pool.query<User>(
|
||||
`SELECT u.id, u.name
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()`,
|
||||
[tokenHash],
|
||||
);
|
||||
const user = result.rows[0];
|
||||
if (!user) return null;
|
||||
await pool.query(
|
||||
"UPDATE sessions SET last_seen_at = now(), expires_at = $1 WHERE token_hash = $2",
|
||||
[new Date(Date.now() + SESSION_TTL_MS), tokenHash],
|
||||
);
|
||||
if (reply) setSessionCookie(reply, token);
|
||||
return user;
|
||||
}
|
||||
|
||||
async function requireUser(request: FastifyRequest, reply: FastifyReply) {
|
||||
const user = await currentUser(request, reply);
|
||||
if (!user) {
|
||||
clearSessionCookie(reply);
|
||||
await reply.code(401).send({ error: "请先登录" });
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ledgerRole(userId: string, ledgerId: string): Promise<MemberRole | null> {
|
||||
const result = await pool.query<{ role: MemberRole }>(
|
||||
`SELECT role FROM ledger_members
|
||||
WHERE ledger_id = $1 AND user_id = $2 AND removed_at IS NULL`,
|
||||
[ledgerId, userId],
|
||||
);
|
||||
return result.rows[0]?.role ?? null;
|
||||
}
|
||||
|
||||
async function createSession(userId: string) {
|
||||
const token = createSecret();
|
||||
await pool.query(
|
||||
"INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
[createId(), userId, hashSecret(token), new Date(Date.now() + SESSION_TTL_MS)],
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
function invitationStatus(invitation: { expiresAt: Date | string; acceptedAt: Date | string | null; revokedAt: Date | string | null }) {
|
||||
if (invitation.revokedAt) return "revoked";
|
||||
if (invitation.acceptedAt) return "accepted";
|
||||
if (new Date(invitation.expiresAt).getTime() <= Date.now()) return "expired";
|
||||
return "valid";
|
||||
}
|
||||
|
||||
server.get("/health", async () => ({ ok: true, service: "cents-api" }));
|
||||
|
||||
server.get("/api/auth/session", async (request, reply) => ({ user: await currentUser(request, reply) }));
|
||||
|
||||
server.post<{ Body: { name?: string; password?: string } }>(
|
||||
"/api/auth/login",
|
||||
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (request, reply) => {
|
||||
const nameKey = normalizeName(request.body.name ?? "");
|
||||
const password = request.body.password ?? "";
|
||||
const result = await pool.query<{ id: string; name: string; passwordHash: string | null }>(
|
||||
`SELECT id, name, password_hash AS "passwordHash" FROM users WHERE name_key = $1`,
|
||||
[nameKey],
|
||||
);
|
||||
const account = result.rows[0];
|
||||
const valid = account?.passwordHash
|
||||
? await verifyPassword(account.passwordHash, password).catch(() => false)
|
||||
: (await hashPassword(password || "invalid-password"), false);
|
||||
if (!account || !valid) return reply.code(401).send({ error: "姓名或密码错误" });
|
||||
const token = await createSession(account.id);
|
||||
setSessionCookie(reply, token);
|
||||
return { user: { id: account.id, name: account.name } };
|
||||
},
|
||||
);
|
||||
|
||||
server.post("/api/auth/logout", async (request, reply) => {
|
||||
const token = request.cookies[sessionCookieName];
|
||||
if (token) await pool.query("UPDATE sessions SET revoked_at = now() WHERE token_hash = $1", [hashSecret(token)]);
|
||||
clearSessionCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
server.patch<{ Body: { name?: string } }>("/api/me", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
const name = request.body.name?.trim() ?? "";
|
||||
if (!name || name.length > 40) return reply.code(400).send({ error: "姓名应为 1 至 40 个字符" });
|
||||
try {
|
||||
const result = await pool.query<User>(
|
||||
"UPDATE users SET name = $1, name_key = $2, updated_at = now() WHERE id = $3 RETURNING id, name",
|
||||
[name, normalizeName(name), user.id],
|
||||
);
|
||||
return { user: result.rows[0] };
|
||||
} catch (error) {
|
||||
if (uniqueViolation(error)) return reply.code(409).send({ error: "该姓名已被使用" });
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
server.get("/api/ledgers", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
const result = await pool.query(
|
||||
`SELECT l.id, l.name, l.color, l.default_currency AS "defaultCurrency",
|
||||
l.created_at AS "createdAt", l.updated_at AS "updatedAt",
|
||||
l.archived_at AS "archivedAt", m.role
|
||||
FROM ledgers l JOIN ledger_members m ON m.ledger_id = l.id
|
||||
WHERE m.user_id = $1 AND m.removed_at IS NULL AND l.archived_at IS NULL
|
||||
ORDER BY l.updated_at DESC`,
|
||||
[user.id],
|
||||
);
|
||||
return { ledgers: result.rows };
|
||||
});
|
||||
|
||||
server.get<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/members", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
if (!await ledgerRole(user.id, request.params.ledgerId)) return reply.code(403).send({ error: "无权访问该账本" });
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.name, m.role, m.joined_at AS "joinedAt"
|
||||
FROM ledger_members m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.ledger_id = $1 AND m.removed_at IS NULL
|
||||
ORDER BY CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, m.joined_at`,
|
||||
[request.params.ledgerId],
|
||||
);
|
||||
return { members: result.rows };
|
||||
});
|
||||
|
||||
server.patch<{
|
||||
Params: { ledgerId: string };
|
||||
Body: { name?: string; color?: string; defaultCurrency?: string };
|
||||
}>("/api/ledgers/:ledgerId", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
|
||||
return reply.code(403).send({ error: "只有账本拥有者可以修改设置" });
|
||||
}
|
||||
const name = request.body.name?.trim() ?? "";
|
||||
if (!name || name.length > 40) return reply.code(400).send({ error: "账本名称无效" });
|
||||
const result = await pool.query(
|
||||
`UPDATE ledgers SET name = $1, color = $2, default_currency = $3, updated_at = now()
|
||||
WHERE id = $4
|
||||
RETURNING id, name, color, default_currency AS "defaultCurrency",
|
||||
created_at AS "createdAt", updated_at AS "updatedAt", archived_at AS "archivedAt"`,
|
||||
[name, request.body.color ?? "#087f72", request.body.defaultCurrency ?? "CNY", request.params.ledgerId],
|
||||
);
|
||||
return { ledger: result.rows[0] };
|
||||
});
|
||||
|
||||
// Application invitations create accounts but never grant access to an existing ledger.
|
||||
server.get<{ Params: { key: string } }>("/api/invitations/:key", async (request, reply) => {
|
||||
const user = await currentUser(request, reply);
|
||||
if (user) return { user, alreadyLoggedIn: true };
|
||||
if (request.params.key.length < 32) return reply.code(404).send({ error: "邀请链接无效" });
|
||||
const result = await pool.query(
|
||||
`SELECT expires_at AS "expiresAt", accepted_at AS "acceptedAt", revoked_at AS "revokedAt"
|
||||
FROM app_invitations WHERE key_hash = $1`,
|
||||
[hashSecret(request.params.key)],
|
||||
);
|
||||
const invitation = result.rows[0];
|
||||
if (!invitation) return reply.code(404).send({ error: "邀请链接无效" });
|
||||
return { invitation: { ...invitation, status: invitationStatus(invitation) }, user: null };
|
||||
});
|
||||
|
||||
server.post<{ Params: { key: string }; Body: { name?: string; password?: string } }>(
|
||||
"/api/invitations/:key/accept",
|
||||
{ config: { rateLimit: { max: 10, timeWindow: "1 minute" } } },
|
||||
async (request, reply) => {
|
||||
const existingUser = await currentUser(request, reply);
|
||||
if (existingUser) return { user: existingUser, created: false };
|
||||
const name = request.body.name?.trim() ?? "";
|
||||
const password = request.body.password ?? "";
|
||||
if (!name || name.length > 40) return reply.code(400).send({ error: "请填写 1 至 40 个字符的姓名" });
|
||||
if (!validPassword(password)) return reply.code(400).send({ error: "密码应为 8 至 128 个字符" });
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await client.query<{
|
||||
id: string; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null;
|
||||
}>(
|
||||
`SELECT id, expires_at AS "expiresAt", accepted_at AS "acceptedAt", revoked_at AS "revokedAt"
|
||||
FROM app_invitations WHERE key_hash = $1 FOR UPDATE`,
|
||||
[hashSecret(request.params.key)],
|
||||
);
|
||||
const invitation = result.rows[0];
|
||||
const status = invitation ? invitationStatus(invitation) : "missing";
|
||||
if (status !== "valid") {
|
||||
await client.query("ROLLBACK");
|
||||
const code = status === "accepted" ? 409 : status === "missing" ? 404 : 410;
|
||||
return reply.code(code).send({ error: status === "accepted" ? "邀请已被使用" : status === "expired" ? "邀请已过期" : status === "revoked" ? "邀请已撤销" : "邀请链接无效" });
|
||||
}
|
||||
|
||||
const user: User = { id: createId(), name };
|
||||
await client.query(
|
||||
"INSERT INTO users (id, name, name_key, password_hash) VALUES ($1, $2, $3, $4)",
|
||||
[user.id, user.name, normalizeName(user.name), passwordHash],
|
||||
);
|
||||
const ledgerId = createId();
|
||||
await client.query("INSERT INTO ledgers (id, name) VALUES ($1, '家庭日常')", [ledgerId]);
|
||||
await client.query(
|
||||
"INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, 'owner')",
|
||||
[ledgerId, user.id],
|
||||
);
|
||||
await client.query(
|
||||
"UPDATE app_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
|
||||
[user.id, invitation!.id],
|
||||
);
|
||||
const sessionToken = createSecret();
|
||||
await client.query(
|
||||
"INSERT INTO sessions (id, user_id, token_hash, expires_at) VALUES ($1, $2, $3, $4)",
|
||||
[createId(), user.id, hashSecret(sessionToken), new Date(Date.now() + SESSION_TTL_MS)],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
setSessionCookie(reply, sessionToken);
|
||||
return { user, created: true };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
if (uniqueViolation(error)) return reply.code(409).send({ error: "该姓名已被使用" });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.post<{ Params: { ledgerId: string } }>("/api/ledgers/:ledgerId/invitations", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
if (await ledgerRole(user.id, request.params.ledgerId) !== "owner") {
|
||||
return reply.code(403).send({ error: "只有账本拥有者可以邀请成员" });
|
||||
}
|
||||
const key = createSecret();
|
||||
const expiresAt = new Date(Date.now() + INVITATION_TTL_MS);
|
||||
await pool.query(
|
||||
`INSERT INTO ledger_invitations (id, ledger_id, key_hash, role, created_by, expires_at)
|
||||
VALUES ($1, $2, $3, 'member', $4, $5)`,
|
||||
[createId(), request.params.ledgerId, hashSecret(key), user.id, expiresAt],
|
||||
);
|
||||
const origin = process.env.PUBLIC_ORIGIN ?? `${request.protocol}://${request.headers.host}`;
|
||||
return { invitation: { key, url: `${origin}/join-ledger?key=${encodeURIComponent(key)}`, expiresAt } };
|
||||
});
|
||||
|
||||
server.get<{ Params: { key: string } }>("/api/ledger-invitations/:key", async (request, reply) => {
|
||||
const result = await pool.query(
|
||||
`SELECT i.expires_at AS "expiresAt", i.accepted_at AS "acceptedAt", i.revoked_at AS "revokedAt",
|
||||
l.id AS "ledgerId", l.name AS "ledgerName", u.name AS "inviterName"
|
||||
FROM ledger_invitations i JOIN ledgers l ON l.id = i.ledger_id
|
||||
LEFT JOIN users u ON u.id = i.created_by WHERE i.key_hash = $1`,
|
||||
[hashSecret(request.params.key)],
|
||||
);
|
||||
const invitation = result.rows[0];
|
||||
if (!invitation) return reply.code(404).send({ error: "账本邀请无效" });
|
||||
return { invitation: { ...invitation, status: invitationStatus(invitation) } };
|
||||
});
|
||||
|
||||
server.post<{ Params: { key: string } }>("/api/ledger-invitations/:key/accept", async (request, reply) => {
|
||||
const user = await requireUser(request, reply);
|
||||
if (!user) return;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const result = await client.query<{
|
||||
id: string; ledgerId: string; role: MemberRole; expiresAt: Date; acceptedAt: Date | null; revokedAt: Date | null;
|
||||
}>(
|
||||
`SELECT id, ledger_id AS "ledgerId", role, expires_at AS "expiresAt",
|
||||
accepted_at AS "acceptedAt", revoked_at AS "revokedAt"
|
||||
FROM ledger_invitations WHERE key_hash = $1 FOR UPDATE`,
|
||||
[hashSecret(request.params.key)],
|
||||
);
|
||||
const invitation = result.rows[0];
|
||||
const status = invitation ? invitationStatus(invitation) : "missing";
|
||||
if (status !== "valid") {
|
||||
await client.query("ROLLBACK");
|
||||
return reply.code(status === "accepted" ? 409 : status === "missing" ? 404 : 410).send({ error: "账本邀请不可用" });
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO ledger_members (ledger_id, user_id, role) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (ledger_id, user_id) DO UPDATE SET removed_at = NULL, joined_at = now()`,
|
||||
[invitation!.ledgerId, user.id, invitation!.role],
|
||||
);
|
||||
await client.query(
|
||||
"UPDATE ledger_invitations SET accepted_by = $1, accepted_at = now() WHERE id = $2",
|
||||
[user.id, invitation!.id],
|
||||
);
|
||||
await client.query("COMMIT");
|
||||
return { ledgerId: invitation!.ledgerId };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
server.post("/api/sync/push", async (request, reply) => {
|
||||
if (!await requireUser(request, reply)) return;
|
||||
return { accepted: true, operations: [] };
|
||||
});
|
||||
|
||||
server.get("/api/sync/pull", async (request, reply) => {
|
||||
if (!await requireUser(request, reply)) return;
|
||||
return { cursor: null, operations: [] };
|
||||
});
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
|
||||
await server.listen({ port, host });
|
||||
|
|
|
|||
Loading…
Reference in New Issue