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.
|
# Production/public domain. Must resolve to the VPS running frps.
|
||||||
DOMAIN=cents.homemade.net.cn
|
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.
|
# Public VPS / frps address.
|
||||||
FRPC_SERVER_HOST=119.28.12.24
|
FRPC_SERVER_HOST=119.28.12.24
|
||||||
FRPC_SERVER_PORT=7000
|
FRPC_SERVER_PORT=7000
|
||||||
|
|
@ -12,3 +19,8 @@ APP_PROTOCOL=http
|
||||||
|
|
||||||
# Use false only after the tunnel is known to work.
|
# Use false only after the tunnel is known to work.
|
||||||
TEST_MODE=false
|
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)
|
||||||
|
- [账号与权限](docs/账号与权限.md)
|
||||||
- [测试策略](docs/测试策略.md)
|
- [测试策略](docs/测试策略.md)
|
||||||
- [内网穿透调试](docs/内网穿透调试.md)
|
- [内网穿透调试](docs/内网穿透调试.md)
|
||||||
- [HTML 原型图](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"];
|
const APP_SHELL = ["/", "/manifest.webmanifest", "/icon.svg"];
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { createEntry } from "../data/entries";
|
import { createEntry } from "../data/entries";
|
||||||
import { useEntryStore } from "../stores/entries";
|
import { useEntryStore } from "../stores/entries";
|
||||||
|
import { useAuthStore } from "../stores/auth";
|
||||||
import { useLedgerStore } from "../stores/ledgers";
|
import { useLedgerStore } from "../stores/ledgers";
|
||||||
import QuickEntryDrawer from "./QuickEntryDrawer.vue";
|
import QuickEntryDrawer from "./QuickEntryDrawer.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const entryStore = useEntryStore();
|
const entryStore = useEntryStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const ledgerStore = useLedgerStore();
|
const ledgerStore = useLedgerStore();
|
||||||
const drawerOpen = ref(false);
|
const drawerOpen = ref(false);
|
||||||
const savedToast = ref(false);
|
const savedToast = ref(false);
|
||||||
|
|
@ -38,6 +40,7 @@ async function saveEntry(input: {
|
||||||
...input,
|
...input,
|
||||||
ledgerId: ledger.id,
|
ledgerId: ledger.id,
|
||||||
currency: ledger.defaultCurrency,
|
currency: ledger.defaultCurrency,
|
||||||
|
createdBy: authStore.user?.id,
|
||||||
}));
|
}));
|
||||||
drawerOpen.value = false;
|
drawerOpen.value = false;
|
||||||
savedToast.value = true;
|
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;
|
categoryId: string;
|
||||||
note: string;
|
note: string;
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
|
createdBy?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseCurrency: CurrencyCode = "CNY";
|
const baseCurrency: CurrencyCode = "CNY";
|
||||||
|
|
@ -29,8 +30,8 @@ export function createEntry(input: EntryInput): LedgerEntry {
|
||||||
categoryId: input.categoryId,
|
categoryId: input.categoryId,
|
||||||
note: input.note,
|
note: input.note,
|
||||||
occurredAt: input.occurredAt,
|
occurredAt: input.occurredAt,
|
||||||
createdBy: "local-user",
|
createdBy: input.createdBy ?? "local-user",
|
||||||
updatedBy: "local-user",
|
updatedBy: input.createdBy ?? "local-user",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export type LedgerRecord = {
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
|
role?: "owner" | "member";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserPreference = {
|
export type UserPreference = {
|
||||||
|
|
@ -18,8 +19,9 @@ export type UserPreference = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const localUserId = "local-user";
|
export function currentLedgerPreferenceId(userId: string) {
|
||||||
export const currentLedgerPreferenceId = `${localUserId}:currentLedgerId`;
|
return `${userId}:currentLedgerId`;
|
||||||
|
}
|
||||||
|
|
||||||
export function createDefaultLedgers(): LedgerRecord[] {
|
export function createDefaultLedgers(): LedgerRecord[] {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
import LedgerView from "./views/LedgerView.vue";
|
import LedgerView from "./views/LedgerView.vue";
|
||||||
|
import { useAuthStore } from "./stores/auth";
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
|
|
@ -9,6 +10,23 @@ export const router = createRouter({
|
||||||
name: "ledger",
|
name: "ledger",
|
||||||
component: LedgerView,
|
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",
|
path: "/prototype/entry-detail",
|
||||||
name: "entry-detail-prototype",
|
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 { defineStore } from "pinia";
|
||||||
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
import type { LedgerEntry, SyncOperation } from "@cents/domain";
|
||||||
import { db } from "../data/db";
|
import { db } from "../data/db";
|
||||||
import { createDemoEntries } from "../data/entries";
|
|
||||||
import { createId } from "../data/ids";
|
import { createId } from "../data/ids";
|
||||||
|
import { useAuthStore } from "./auth";
|
||||||
|
|
||||||
export const useEntryStore = defineStore("entries", {
|
export const useEntryStore = defineStore("entries", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
|
|
@ -10,9 +10,6 @@ export const useEntryStore = defineStore("entries", {
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async loadEntries() {
|
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(
|
this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter(
|
||||||
(entry) => entry.deletedAt === null,
|
(entry) => entry.deletedAt === null,
|
||||||
);
|
);
|
||||||
|
|
@ -37,6 +34,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
await this.loadEntries();
|
await this.loadEntries();
|
||||||
},
|
},
|
||||||
async updateEntry(entry: LedgerEntry) {
|
async updateEntry(entry: LedgerEntry) {
|
||||||
|
const auth = useAuthStore();
|
||||||
const existing = await db.entries.get(entry.id);
|
const existing = await db.entries.get(entry.id);
|
||||||
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
if (!existing || existing.deletedAt) throw new Error("Entry not found");
|
||||||
|
|
||||||
|
|
@ -44,7 +42,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
...existing,
|
...existing,
|
||||||
...entry,
|
...entry,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
updatedBy: "local-user",
|
updatedBy: auth.user?.id ?? existing.updatedBy,
|
||||||
version: existing.version + 1,
|
version: existing.version + 1,
|
||||||
};
|
};
|
||||||
const operation: SyncOperation = {
|
const operation: SyncOperation = {
|
||||||
|
|
@ -67,6 +65,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
async deleteEntry(entryId: string) {
|
async deleteEntry(entryId: string) {
|
||||||
|
const auth = useAuthStore();
|
||||||
const existing = await db.entries.get(entryId);
|
const existing = await db.entries.get(entryId);
|
||||||
if (!existing || existing.deletedAt) return;
|
if (!existing || existing.deletedAt) return;
|
||||||
|
|
||||||
|
|
@ -75,7 +74,7 @@ export const useEntryStore = defineStore("entries", {
|
||||||
...existing,
|
...existing,
|
||||||
deletedAt,
|
deletedAt,
|
||||||
updatedAt: deletedAt,
|
updatedAt: deletedAt,
|
||||||
updatedBy: "local-user",
|
updatedBy: auth.user?.id ?? existing.updatedBy,
|
||||||
version: existing.version + 1,
|
version: existing.version + 1,
|
||||||
};
|
};
|
||||||
const operation: SyncOperation = {
|
const operation: SyncOperation = {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
import { apiRequest } from "../data/api";
|
||||||
import { db } from "../data/db";
|
import { db } from "../data/db";
|
||||||
import {
|
import { currentLedgerPreferenceId, type LedgerRecord, type UserPreference } from "../data/ledgers";
|
||||||
createDefaultLedgers,
|
import { useAuthStore } from "./auth";
|
||||||
currentLedgerPreferenceId,
|
|
||||||
localUserId,
|
export type LedgerMember = {
|
||||||
type LedgerRecord,
|
id: string;
|
||||||
type UserPreference,
|
name: string;
|
||||||
} from "../data/ledgers";
|
role: "owner" | "member";
|
||||||
|
joinedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
let ledgerLoadPromise: Promise<void> | null = null;
|
let ledgerLoadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
|
@ -14,29 +17,45 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
ledgers: [] as LedgerRecord[],
|
ledgers: [] as LedgerRecord[],
|
||||||
currentLedgerId: "",
|
currentLedgerId: "",
|
||||||
|
currentMembers: [] as LedgerMember[],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
currentLedger(state) {
|
currentLedger(state) {
|
||||||
return state.ledgers.find((ledger) => ledger.id === state.currentLedgerId) ?? null;
|
return state.ledgers.find((ledger) => ledger.id === state.currentLedgerId) ?? null;
|
||||||
},
|
},
|
||||||
|
currentRole(): "owner" | "member" | null {
|
||||||
|
return this.currentLedger?.role ?? null;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
async loadLedgers() {
|
async loadLedgers() {
|
||||||
if (this.loaded) return;
|
if (this.loaded) return;
|
||||||
if (ledgerLoadPromise) return ledgerLoadPromise;
|
if (ledgerLoadPromise) return ledgerLoadPromise;
|
||||||
ledgerLoadPromise = (async () => {
|
ledgerLoadPromise = (async () => {
|
||||||
if ((await db.ledgers.count()) === 0) await db.ledgers.bulkAdd(createDefaultLedgers());
|
const auth = useAuthStore();
|
||||||
this.ledgers = (await db.ledgers.orderBy("updatedAt").reverse().toArray()).filter(
|
if (!auth.user) return;
|
||||||
(ledger) => ledger.archivedAt === null,
|
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 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 ?? this.ledgers[0]?.id ?? "";
|
||||||
this.currentLedgerId = preferredLedger?.id ?? fallbackLedger?.id ?? "";
|
|
||||||
if (this.currentLedgerId !== preference?.value) await this.persistCurrentLedger();
|
if (this.currentLedgerId !== preference?.value) await this.persistCurrentLedger();
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
|
await this.loadCurrentMembers();
|
||||||
})();
|
})();
|
||||||
try {
|
try {
|
||||||
await ledgerLoadPromise;
|
await ledgerLoadPromise;
|
||||||
|
|
@ -44,36 +63,47 @@ export const useLedgerStore = defineStore("ledgers", {
|
||||||
ledgerLoadPromise = null;
|
ledgerLoadPromise = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async reloadLedgers() {
|
||||||
|
this.loaded = false;
|
||||||
|
this.currentMembers = [];
|
||||||
|
await this.loadLedgers();
|
||||||
|
},
|
||||||
async setCurrentLedger(ledgerId: string) {
|
async setCurrentLedger(ledgerId: string) {
|
||||||
const ledger = this.ledgers.find((item) => item.id === ledgerId && item.archivedAt === null);
|
const ledger = this.ledgers.find((item) => item.id === ledgerId && item.archivedAt === null);
|
||||||
if (!ledger) throw new Error("Ledger not found");
|
if (!ledger) throw new Error("Ledger not found");
|
||||||
this.currentLedgerId = ledger.id;
|
this.currentLedgerId = ledger.id;
|
||||||
await this.persistCurrentLedger();
|
await this.persistCurrentLedger();
|
||||||
|
await this.loadCurrentMembers();
|
||||||
},
|
},
|
||||||
async persistCurrentLedger() {
|
async persistCurrentLedger() {
|
||||||
if (!this.currentLedgerId) return;
|
const auth = useAuthStore();
|
||||||
|
if (!this.currentLedgerId || !auth.user) return;
|
||||||
const preference: UserPreference = {
|
const preference: UserPreference = {
|
||||||
id: currentLedgerPreferenceId,
|
id: currentLedgerPreferenceId(auth.user.id),
|
||||||
userId: localUserId,
|
userId: auth.user.id,
|
||||||
key: "currentLedgerId",
|
key: "currentLedgerId",
|
||||||
value: this.currentLedgerId,
|
value: this.currentLedgerId,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
await db.userPreferences.put(preference);
|
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">) {
|
async updateLedger(input: Pick<LedgerRecord, "id" | "name" | "color" | "defaultCurrency">) {
|
||||||
const existing = await db.ledgers.get(input.id);
|
await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input });
|
||||||
if (!existing) throw new Error("Ledger not found");
|
await this.reloadLedgers();
|
||||||
const updated: LedgerRecord = {
|
return this.ledgers.find((ledger) => ledger.id === input.id)!;
|
||||||
...existing,
|
|
||||||
...input,
|
|
||||||
name: input.name.trim(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await db.ledgers.put(updated);
|
|
||||||
this.loaded = false;
|
|
||||||
await this.loadLedgers();
|
|
||||||
return updated;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,11 @@ input:focus-visible {
|
||||||
background: #3478e5;
|
background: #3478e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.participant-avatar.member,
|
||||||
|
.share-member-avatar.member {
|
||||||
|
background: #3478e5;
|
||||||
|
}
|
||||||
|
|
||||||
.participant-avatar.member-li,
|
.participant-avatar.member-li,
|
||||||
.share-member-avatar.member-li {
|
.share-member-avatar.member-li {
|
||||||
background: #7559d9;
|
background: #7559d9;
|
||||||
|
|
|
||||||
|
|
@ -272,7 +272,7 @@ async function deleteEntry() {
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</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>
|
<p v-if="errorMessage" class="entry-save-error" role="alert">{{ errorMessage }}</p>
|
||||||
</div>
|
</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 () => {
|
onMounted(async () => {
|
||||||
await ledgerStore.loadLedgers();
|
await ledgerStore.loadLedgers();
|
||||||
|
if (ledgerStore.currentRole !== "owner") {
|
||||||
|
await router.replace("/");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!ledgerStore.currentLedger) return;
|
if (!ledgerStore.currentLedger) return;
|
||||||
name.value = ledgerStore.currentLedger.name;
|
name.value = ledgerStore.currentLedger.name;
|
||||||
color.value = ledgerStore.currentLedger.color;
|
color.value = ledgerStore.currentLedger.color;
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
import QuickEntryHost from "../components/QuickEntryHost.vue";
|
||||||
|
import { apiRequest, ApiError } from "../data/api";
|
||||||
import { findCategory, findCategoryPath } from "../data/categories";
|
import { findCategory, findCategoryPath } from "../data/categories";
|
||||||
import { useEntryStore } from "../stores/entries";
|
import { useEntryStore } from "../stores/entries";
|
||||||
import { useLedgerStore } from "../stores/ledgers";
|
import { useLedgerStore } from "../stores/ledgers";
|
||||||
|
|
@ -27,6 +28,8 @@ const moreMenuOpen = ref(false);
|
||||||
const participantOpen = ref(false);
|
const participantOpen = ref(false);
|
||||||
const shareOpen = ref(false);
|
const shareOpen = ref(false);
|
||||||
const shareFeedback = ref("");
|
const shareFeedback = ref("");
|
||||||
|
const invitationLink = ref("");
|
||||||
|
const generatingInvitation = ref(false);
|
||||||
const visibleCount = ref(ENTRY_PAGE_SIZE);
|
const visibleCount = ref(ENTRY_PAGE_SIZE);
|
||||||
const ledgerContent = ref<HTMLElement | null>(null);
|
const ledgerContent = ref<HTMLElement | null>(null);
|
||||||
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
const loadMoreTrigger = ref<HTMLElement | null>(null);
|
||||||
|
|
@ -155,12 +158,14 @@ function categoryLabel(entry: LedgerEntry) {
|
||||||
|
|
||||||
function openParticipantSheet() {
|
function openParticipantSheet() {
|
||||||
participantOpen.value = true;
|
participantOpen.value = true;
|
||||||
|
void ledgerStore.loadCurrentMembers();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openShareSheet() {
|
async function openShareSheet() {
|
||||||
moreMenuOpen.value = false;
|
moreMenuOpen.value = false;
|
||||||
shareFeedback.value = "";
|
shareFeedback.value = "正在生成邀请链接";
|
||||||
shareOpen.value = true;
|
shareOpen.value = true;
|
||||||
|
await ensureInvitation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLedgerSettings() {
|
function openLedgerSettings() {
|
||||||
|
|
@ -168,14 +173,27 @@ function openLedgerSettings() {
|
||||||
router.push("/ledger/settings");
|
router.push("/ledger/settings");
|
||||||
}
|
}
|
||||||
|
|
||||||
function invitationUrl() {
|
async function ensureInvitation() {
|
||||||
const url = new URL(window.location.origin);
|
if (invitationLink.value || generatingInvitation.value || !ledgerStore.currentLedgerId) return invitationLink.value;
|
||||||
url.searchParams.set("invite", ledgerStore.currentLedgerId);
|
generatingInvitation.value = true;
|
||||||
return url.toString();
|
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() {
|
async function copyInvitation() {
|
||||||
const link = invitationUrl();
|
const link = await ensureInvitation();
|
||||||
|
if (!link) return;
|
||||||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link);
|
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link);
|
||||||
else {
|
else {
|
||||||
const input = document.createElement("textarea");
|
const input = document.createElement("textarea");
|
||||||
|
|
@ -189,10 +207,12 @@ async function copyInvitation() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shareLedger() {
|
async function shareLedger() {
|
||||||
|
const url = await ensureInvitation();
|
||||||
|
if (!url) return;
|
||||||
const data = {
|
const data = {
|
||||||
title: ledgerStore.currentLedger?.name ?? "账本",
|
title: ledgerStore.currentLedger?.name ?? "账本",
|
||||||
text: `邀请你加入我的${ledgerStore.currentLedger?.name ?? "账本"}`,
|
text: `邀请你加入我的${ledgerStore.currentLedger?.name ?? "账本"}`,
|
||||||
url: invitationUrl(),
|
url,
|
||||||
};
|
};
|
||||||
if (!navigator.share) {
|
if (!navigator.share) {
|
||||||
await copyInvitation();
|
await copyInvitation();
|
||||||
|
|
@ -214,15 +234,15 @@ async function shareLedger() {
|
||||||
<header class="ledger-header">
|
<header class="ledger-header">
|
||||||
<div class="top-actions">
|
<div class="top-actions">
|
||||||
<button class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
|
<button class="participant-avatars" type="button" aria-label="查看账本参与者" title="账本参与者" @click="openParticipantSheet">
|
||||||
<span class="participant-avatar owner">我</span>
|
<span v-for="member in ledgerStore.currentMembers.slice(0, 3)" :key="member.id" class="participant-avatar" :class="member.role">
|
||||||
<span class="participant-avatar member-wang">王</span>
|
{{ member.name.slice(0, 1) }}
|
||||||
<span class="participant-avatar member-li">李</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
|
<button class="ledger-switcher" type="button" @click="router.push('/ledgers')">
|
||||||
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
|
<span>{{ ledgerStore.currentLedger?.name ?? "选择账本" }}</span>
|
||||||
<ChevronDown :size="16" />
|
<ChevronDown :size="16" />
|
||||||
</button>
|
</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" />
|
<MoreHorizontal :size="21" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -296,7 +316,7 @@ async function shareLedger() {
|
||||||
<div class="entry-copy">
|
<div class="entry-copy">
|
||||||
<strong>{{ entry.note || categoryLabel(entry) }}</strong>
|
<strong>{{ entry.note || categoryLabel(entry) }}</strong>
|
||||||
<div class="entry-subline">
|
<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>
|
<span class="entry-category-label">{{ categoryLabel(entry) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -329,23 +349,15 @@ async function shareLedger() {
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ ledgerStore.currentLedger?.name ?? "账本" }}</strong>
|
<strong>{{ ledgerStore.currentLedger?.name ?? "账本" }}</strong>
|
||||||
<span>3 位参与者</span>
|
<span>{{ ledgerStore.currentMembers.length }} 位参与者</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" aria-label="关闭" title="关闭" @click="participantOpen = false"><X :size="20" /></button>
|
<button type="button" aria-label="关闭" title="关闭" @click="participantOpen = false"><X :size="20" /></button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="share-member-list" aria-label="当前参与者">
|
<div class="share-member-list" aria-label="当前参与者">
|
||||||
<div class="share-member-row">
|
<div v-for="member in ledgerStore.currentMembers" :key="member.id" class="share-member-row">
|
||||||
<span class="share-member-avatar owner">我</span>
|
<span class="share-member-avatar" :class="member.role">{{ member.name.slice(0, 1) }}</span>
|
||||||
<span><strong>自己</strong><small>拥有者</small></span>
|
<span><strong>{{ member.name }}</strong><small>{{ member.role === "owner" ? "拥有者" : "成员" }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -366,8 +378,8 @@ async function shareLedger() {
|
||||||
<button type="button" aria-label="关闭" title="关闭" @click="shareOpen = false"><X :size="20" /></button>
|
<button type="button" aria-label="关闭" title="关闭" @click="shareOpen = false"><X :size="20" /></button>
|
||||||
</header>
|
</header>
|
||||||
<div class="share-actions">
|
<div class="share-actions">
|
||||||
<button type="button" class="copy-invite-action" @click="copyInvitation"><Copy :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" @click="shareLedger"><Share2 :size="18" />分享账本</button>
|
<button type="button" class="system-share-action" :disabled="generatingInvitation" @click="shareLedger"><Share2 :size="18" />分享账本</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="share-feedback" role="status">{{ shareFeedback || "通过邀请链接加入此账本" }}</p>
|
<p class="share-feedback" role="status">{{ shareFeedback || "通过邀请链接加入此账本" }}</p>
|
||||||
</section>
|
</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">
|
<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 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="app-shell me-shell">
|
<main class="app-shell me-shell">
|
||||||
<header class="me-header"><strong>我的</strong></header>
|
<header class="me-header"><strong>我的</strong></header>
|
||||||
<div class="me-scroll">
|
<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">
|
<section class="me-settings">
|
||||||
<button type="button"><Bell :size="19" /><span>通知与提醒</span><ChevronRight :size="18" /></button>
|
<button type="button"><Bell :size="19" /><span>通知与提醒</span><ChevronRight :size="18" /></button>
|
||||||
<button type="button"><Cloud :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 { 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-header strong { font-size:20px; }
|
||||||
.me-scroll { height:calc(100% - 70px); overflow-y:auto; padding:14px 16px 104px; }
|
.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 > 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 > div { min-width:0; display:grid; gap:2px; }
|
||||||
.profile-band strong { font-size:16px; }
|
.profile-band strong { overflow:hidden; font-size:16px; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.profile-band small { color:#7b8884; font-size:11px; }
|
.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 { 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 { 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 button:last-child { border-bottom:0; }
|
||||||
.me-settings span { color:#26342f; font-size:14px; }
|
.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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,17 @@ const categoryStats = computed(() => {
|
||||||
|
|
||||||
const maxCategoryAmount = computed(() => Math.max(1, ...categoryStats.value.map((item) => item.amount)));
|
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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
|
await Promise.all([entryStore.loadEntries(), ledgerStore.loadLedgers()]);
|
||||||
});
|
});
|
||||||
|
|
@ -141,11 +152,11 @@ function money(value: number) {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="stats-section" aria-label="记录者统计">
|
<section v-else class="stats-section" aria-label="记录者统计">
|
||||||
<header><strong>记录者</strong><span>1 人</span></header>
|
<header><strong>记录者</strong><span>{{ memberStats.length }} 人</span></header>
|
||||||
<div class="member-stat-row">
|
<div v-for="member in memberStats" :key="member.userId" class="member-stat-row">
|
||||||
<span><CircleUserRound :size="22" /></span>
|
<span><CircleUserRound :size="22" /></span>
|
||||||
<div><strong>小王</strong><small>{{ entries.length }} 笔记录</small></div>
|
<div><strong>{{ member.name }}</strong><small>{{ member.count }} 笔记录</small></div>
|
||||||
<div><small class="income">收入 {{ money(totals.income) }}</small><small class="expense">支出 {{ money(totals.expense) }}</small></div>
|
<div><small class="income">收入 {{ money(member.income) }}</small><small class="expense">支出 {{ money(member.expense) }}</small></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,77 @@
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
const monorepoRoot = new URL("../..", import.meta.url).pathname;
|
||||||
plugins: [vue()],
|
|
||||||
|
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: {
|
server: {
|
||||||
allowedHosts: ["dev.cents.homemade.net.cn"],
|
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:
|
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:
|
frpc:
|
||||||
image: homemade/auto-cert/proxy:latest
|
image: homemade/auto-cert/proxy:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
@ -26,9 +43,9 @@ services:
|
||||||
- frp_dev
|
- frp_dev
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
cents_dev_db:
|
||||||
frp_cert_volume:
|
frp_cert_volume:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
frp_dev:
|
frp_dev:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,8 @@
|
||||||
|
|
||||||
### 账号与访问
|
### 账号与访问
|
||||||
|
|
||||||
- 不开放自助注册。
|
- 不开放自助注册,只能通过 7 天有效的单次应用邀请建立账号。
|
||||||
- 后台分配用户名密码。
|
- 使用姓名和密码登录;姓名必须唯一,密码由管理员在后台重置。
|
||||||
- 设备 ID 审批作为备选方案。
|
|
||||||
- 用户只能访问自己有权限的账本。
|
- 用户只能访问自己有权限的账本。
|
||||||
|
|
||||||
### 账本
|
### 账本
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"resolved": "apps/web",
|
||||||
"link": true
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||||
|
|
@ -529,6 +560,39 @@
|
||||||
"fast-uri": "^3.0.0"
|
"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": {
|
"node_modules/@fastify/error": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
|
||||||
|
|
@ -580,6 +644,26 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@fastify/merge-json-schemas": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
|
"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"
|
"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": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
|
@ -634,6 +739,276 @@
|
||||||
"vue": ">=3.0.1"
|
"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": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
|
@ -971,6 +1346,16 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|
@ -987,6 +1372,18 @@
|
||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "6.0.8",
|
"version": "6.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
|
||||||
|
|
@ -1481,6 +1878,22 @@
|
||||||
"toad-cache": "^3.7.0"
|
"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": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"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": "^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": {
|
"node_modules/hookable": {
|
||||||
"version": "5.5.3",
|
"version": "5.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
|
||||||
|
|
@ -1686,6 +2111,95 @@
|
||||||
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -1790,6 +2304,45 @@
|
||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
|
@ -2055,6 +2608,13 @@
|
||||||
"node": ">=20"
|
"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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.23.1",
|
"version": "4.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||||
|
|
@ -2235,6 +2795,15 @@
|
||||||
"typescript": ">=5.0.0"
|
"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": {
|
"packages/domain": {
|
||||||
"name": "@cents/domain",
|
"name": "@cents/domain",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|
@ -2247,10 +2816,16 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cents/domain": "0.1.0",
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,23 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"bootstrap": "tsx src/bootstrap.ts",
|
||||||
|
"password:reset": "tsx src/reset-password.ts",
|
||||||
"build": "tsc --noEmit",
|
"build": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cents/domain": "0.1.0",
|
"@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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.14",
|
"@types/node": "^22.13.14",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.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({
|
type User = { id: string; name: string };
|
||||||
logger: true,
|
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 () => ({
|
server.addHook("onSend", async (request, reply, payload) => {
|
||||||
ok: true,
|
if (
|
||||||
service: "cents-api",
|
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 () => ({
|
function uniqueViolation(error: unknown) {
|
||||||
accepted: true,
|
return typeof error === "object" && error !== null && "code" in error && error.code === "23505";
|
||||||
operations: [],
|
}
|
||||||
}));
|
|
||||||
|
|
||||||
server.get("/sync/pull", async () => ({
|
async function currentUser(request: FastifyRequest, reply?: FastifyReply): Promise<User | null> {
|
||||||
cursor: null,
|
const token = request.cookies[sessionCookieName];
|
||||||
operations: [],
|
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 port = Number(process.env.PORT ?? 3000);
|
||||||
const host = process.env.HOST ?? "0.0.0.0";
|
const host = process.env.HOST ?? "0.0.0.0";
|
||||||
|
|
||||||
await server.listen({ port, host });
|
await server.listen({ port, host });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue