Update app configuration and account flows

This commit is contained in:
openclaw 2026-07-21 23:34:49 +08:00
parent 7cc2f51594
commit 5d5be69603
33 changed files with 1897 additions and 122 deletions

View File

@ -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

View File

@ -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)

View File

@ -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"
}
]
}

View File

@ -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) => {

View File

@ -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;

23
apps/web/src/data/api.ts Normal file
View File

@ -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;
}

View File

@ -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,

View File

@ -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();

View File

@ -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;
});

View File

@ -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;
},
},
});

View File

@ -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 = {

View File

@ -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;
}, },
}, },
}); });

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
}, },
}); },
},
}));

1
codex-tmux-scroll Submodule

@ -0,0 +1 @@
Subproject commit f5d59a3f9222285eb9c179bd33574099a4325ec0

View File

@ -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

View File

@ -14,9 +14,8 @@
### 账号与访问 ### 账号与访问
- 不开放自助注册。 - 不开放自助注册,只能通过 7 天有效的单次应用邀请建立账号。
- 后台分配用户名密码。 - 使用姓名和密码登录;姓名必须唯一,密码由管理员在后台重置。
- 设备 ID 审批作为备选方案。
- 用户只能访问自己有权限的账本。 - 用户只能访问自己有权限的账本。
### 账本 ### 账本

61
docs/账号与权限.md Normal file
View File

@ -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 同时启用安全响应头和请求限速。

577
package-lock.json generated
View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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();

88
services/api/src/db.ts Normal file
View File

@ -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);
}

View File

@ -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();
}

View File

@ -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",
});
}

View File

@ -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 });