From 5d5be6960306311ef89932b6948a0f86aaf5fc16 Mon Sep 17 00:00:00 2001 From: openclaw Date: Tue, 21 Jul 2026 23:34:49 +0800 Subject: [PATCH] Update app configuration and account flows --- .env.example | 12 + README.md | 1 + apps/web/public/manifest.webmanifest | 18 - apps/web/public/sw.js | 2 +- apps/web/src/components/QuickEntryHost.vue | 3 + apps/web/src/data/api.ts | 23 + apps/web/src/data/entries.ts | 5 +- apps/web/src/data/ledgers.ts | 6 +- apps/web/src/router.ts | 29 ++ apps/web/src/stores/auth.ts | 58 +++ apps/web/src/stores/entries.ts | 11 +- apps/web/src/stores/ledgers.ts | 88 ++-- apps/web/src/styles.css | 5 + apps/web/src/views/EntryDetailView.vue | 2 +- apps/web/src/views/InviteView.vue | 132 +++++ apps/web/src/views/JoinLedgerView.vue | 81 +++ apps/web/src/views/LedgerSettingsView.vue | 4 + apps/web/src/views/LedgerView.vue | 66 ++- apps/web/src/views/LoginView.vue | 71 +++ apps/web/src/views/MeView.vue | 59 ++- apps/web/src/views/StatsView.vue | 19 +- apps/web/vite.config.ts | 76 ++- codex-tmux-scroll | 1 + compose.dev.yaml | 19 +- docs/产品边界.md | 5 +- docs/账号与权限.md | 61 +++ package-lock.json | 577 ++++++++++++++++++++- services/api/package.json | 10 +- services/api/src/bootstrap.ts | 16 + services/api/src/db.ts | 88 ++++ services/api/src/reset-password.ts | 30 ++ services/api/src/security.ts | 61 +++ services/api/src/server.ts | 380 +++++++++++++- 33 files changed, 1897 insertions(+), 122 deletions(-) delete mode 100644 apps/web/public/manifest.webmanifest create mode 100644 apps/web/src/data/api.ts create mode 100644 apps/web/src/stores/auth.ts create mode 100644 apps/web/src/views/InviteView.vue create mode 100644 apps/web/src/views/JoinLedgerView.vue create mode 100644 apps/web/src/views/LoginView.vue create mode 160000 codex-tmux-scroll create mode 100644 docs/账号与权限.md create mode 100644 services/api/src/bootstrap.ts create mode 100644 services/api/src/db.ts create mode 100644 services/api/src/reset-password.ts create mode 100644 services/api/src/security.ts diff --git a/.env.example b/.env.example index c21c2c4..249d033 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,13 @@ # Production/public domain. Must resolve to the VPS running frps. DOMAIN=cents.homemade.net.cn +# App install identity. Use "dev" for cents-dev and "pro" for cents-pro. +APP_VARIANT=dev +# Optional overrides for the PWA install name/id. +# VITE_APP_NAME=Cents Dev +# VITE_APP_SHORT_NAME=Cents Dev +# VITE_APP_ID=/?app=cents-dev + # Public VPS / frps address. FRPC_SERVER_HOST=119.28.12.24 FRPC_SERVER_PORT=7000 @@ -12,3 +19,8 @@ APP_PROTOCOL=http # Use false only after the tunnel is known to work. TEST_MODE=false + +# API and PostgreSQL. The development database is provided by compose.dev.yaml. +DATABASE_URL=postgres://cents:cents-dev-only@127.0.0.1:55432/cents +PUBLIC_ORIGIN=https://dev.cents.homemade.net.cn +COOKIE_SECURE=true diff --git a/README.md b/README.md index a7b9457..d8a3a3e 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ - [产品边界](docs/产品边界.md) - [技术方案](docs/技术方案.md) - [数据与同步](docs/数据与同步.md) +- [账号与权限](docs/账号与权限.md) - [测试策略](docs/测试策略.md) - [内网穿透调试](docs/内网穿透调试.md) - [HTML 原型图](docs/原型图/原型说明.md) diff --git a/apps/web/public/manifest.webmanifest b/apps/web/public/manifest.webmanifest deleted file mode 100644 index b73dbf8..0000000 --- a/apps/web/public/manifest.webmanifest +++ /dev/null @@ -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" - } - ] -} - diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 0f3ac0e..692b697 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = "cents-shell-v2"; +const CACHE_NAME = "cents-shell-v3"; const APP_SHELL = ["/", "/manifest.webmanifest", "/icon.svg"]; self.addEventListener("install", (event) => { diff --git a/apps/web/src/components/QuickEntryHost.vue b/apps/web/src/components/QuickEntryHost.vue index 4d5544e..3357699 100644 --- a/apps/web/src/components/QuickEntryHost.vue +++ b/apps/web/src/components/QuickEntryHost.vue @@ -4,11 +4,13 @@ import { computed, onMounted, ref } from "vue"; import { useRoute } from "vue-router"; import { createEntry } from "../data/entries"; import { useEntryStore } from "../stores/entries"; +import { useAuthStore } from "../stores/auth"; import { useLedgerStore } from "../stores/ledgers"; import QuickEntryDrawer from "./QuickEntryDrawer.vue"; const route = useRoute(); const entryStore = useEntryStore(); +const authStore = useAuthStore(); const ledgerStore = useLedgerStore(); const drawerOpen = ref(false); const savedToast = ref(false); @@ -38,6 +40,7 @@ async function saveEntry(input: { ...input, ledgerId: ledger.id, currency: ledger.defaultCurrency, + createdBy: authStore.user?.id, })); drawerOpen.value = false; savedToast.value = true; diff --git a/apps/web/src/data/api.ts b/apps/web/src/data/api.ts new file mode 100644 index 0000000..a281cb6 --- /dev/null +++ b/apps/web/src/data/api.ts @@ -0,0 +1,23 @@ +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + } +} + +export async function apiRequest( + path: string, + options: { method?: string; body?: unknown } = {}, +): Promise { + 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; +} diff --git a/apps/web/src/data/entries.ts b/apps/web/src/data/entries.ts index d995102..5016940 100644 --- a/apps/web/src/data/entries.ts +++ b/apps/web/src/data/entries.ts @@ -9,6 +9,7 @@ type EntryInput = { categoryId: string; note: string; occurredAt: string; + createdBy?: string; }; const baseCurrency: CurrencyCode = "CNY"; @@ -29,8 +30,8 @@ export function createEntry(input: EntryInput): LedgerEntry { categoryId: input.categoryId, note: input.note, occurredAt: input.occurredAt, - createdBy: "local-user", - updatedBy: "local-user", + createdBy: input.createdBy ?? "local-user", + updatedBy: input.createdBy ?? "local-user", createdAt: now, updatedAt: now, deletedAt: null, diff --git a/apps/web/src/data/ledgers.ts b/apps/web/src/data/ledgers.ts index 59dbb6b..8e0a060 100644 --- a/apps/web/src/data/ledgers.ts +++ b/apps/web/src/data/ledgers.ts @@ -8,6 +8,7 @@ export type LedgerRecord = { createdAt: string; updatedAt: string; archivedAt: string | null; + role?: "owner" | "member"; }; export type UserPreference = { @@ -18,8 +19,9 @@ export type UserPreference = { updatedAt: string; }; -export const localUserId = "local-user"; -export const currentLedgerPreferenceId = `${localUserId}:currentLedgerId`; +export function currentLedgerPreferenceId(userId: string) { + return `${userId}:currentLedgerId`; +} export function createDefaultLedgers(): LedgerRecord[] { const now = new Date().toISOString(); diff --git a/apps/web/src/router.ts b/apps/web/src/router.ts index 83a69dc..34da4b0 100644 --- a/apps/web/src/router.ts +++ b/apps/web/src/router.ts @@ -1,5 +1,6 @@ import { createRouter, createWebHistory } from "vue-router"; import LedgerView from "./views/LedgerView.vue"; +import { useAuthStore } from "./stores/auth"; export const router = createRouter({ history: createWebHistory(), @@ -9,6 +10,23 @@ export const router = createRouter({ name: "ledger", component: LedgerView, }, + { + path: "/invite", + name: "invite", + component: () => import("./views/InviteView.vue"), + meta: { public: true }, + }, + { + path: "/login", + name: "login", + component: () => import("./views/LoginView.vue"), + meta: { public: true }, + }, + { + path: "/join-ledger", + name: "join-ledger", + component: () => import("./views/JoinLedgerView.vue"), + }, { path: "/prototype/entry-detail", name: "entry-detail-prototype", @@ -41,3 +59,14 @@ export const router = createRouter({ }, ], }); + +router.beforeEach(async (to) => { + const auth = useAuthStore(); + await auth.loadSession(); + if (to.meta.public) { + if (to.name === "login" && auth.user) return { name: "ledger" }; + return true; + } + if (!auth.user) return { name: "login", query: { redirect: to.fullPath } }; + return true; +}); diff --git a/apps/web/src/stores/auth.ts b/apps/web/src/stores/auth.ts new file mode 100644 index 0000000..99baf43 --- /dev/null +++ b/apps/web/src/stores/auth.ts @@ -0,0 +1,58 @@ +import { defineStore } from "pinia"; +import { apiRequest } from "../data/api"; + +export type SessionUser = { id: string; name: string }; + +let sessionPromise: Promise | 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; + }, + }, +}); diff --git a/apps/web/src/stores/entries.ts b/apps/web/src/stores/entries.ts index a59f722..7fba325 100644 --- a/apps/web/src/stores/entries.ts +++ b/apps/web/src/stores/entries.ts @@ -1,8 +1,8 @@ import { defineStore } from "pinia"; import type { LedgerEntry, SyncOperation } from "@cents/domain"; import { db } from "../data/db"; -import { createDemoEntries } from "../data/entries"; import { createId } from "../data/ids"; +import { useAuthStore } from "./auth"; export const useEntryStore = defineStore("entries", { state: () => ({ @@ -10,9 +10,6 @@ export const useEntryStore = defineStore("entries", { }), actions: { async loadEntries() { - if (import.meta.env.DEV && (await db.entries.count()) === 0) { - await db.entries.bulkAdd(createDemoEntries()); - } this.entries = (await db.entries.orderBy("occurredAt").reverse().toArray()).filter( (entry) => entry.deletedAt === null, ); @@ -37,6 +34,7 @@ export const useEntryStore = defineStore("entries", { await this.loadEntries(); }, async updateEntry(entry: LedgerEntry) { + const auth = useAuthStore(); const existing = await db.entries.get(entry.id); if (!existing || existing.deletedAt) throw new Error("Entry not found"); @@ -44,7 +42,7 @@ export const useEntryStore = defineStore("entries", { ...existing, ...entry, updatedAt: new Date().toISOString(), - updatedBy: "local-user", + updatedBy: auth.user?.id ?? existing.updatedBy, version: existing.version + 1, }; const operation: SyncOperation = { @@ -67,6 +65,7 @@ export const useEntryStore = defineStore("entries", { return updated; }, async deleteEntry(entryId: string) { + const auth = useAuthStore(); const existing = await db.entries.get(entryId); if (!existing || existing.deletedAt) return; @@ -75,7 +74,7 @@ export const useEntryStore = defineStore("entries", { ...existing, deletedAt, updatedAt: deletedAt, - updatedBy: "local-user", + updatedBy: auth.user?.id ?? existing.updatedBy, version: existing.version + 1, }; const operation: SyncOperation = { diff --git a/apps/web/src/stores/ledgers.ts b/apps/web/src/stores/ledgers.ts index fb2e43d..a8062fd 100644 --- a/apps/web/src/stores/ledgers.ts +++ b/apps/web/src/stores/ledgers.ts @@ -1,12 +1,15 @@ import { defineStore } from "pinia"; +import { apiRequest } from "../data/api"; import { db } from "../data/db"; -import { - createDefaultLedgers, - currentLedgerPreferenceId, - localUserId, - type LedgerRecord, - type UserPreference, -} from "../data/ledgers"; +import { currentLedgerPreferenceId, type LedgerRecord, type UserPreference } from "../data/ledgers"; +import { useAuthStore } from "./auth"; + +export type LedgerMember = { + id: string; + name: string; + role: "owner" | "member"; + joinedAt: string; +}; let ledgerLoadPromise: Promise | null = null; @@ -14,29 +17,45 @@ export const useLedgerStore = defineStore("ledgers", { state: () => ({ ledgers: [] as LedgerRecord[], currentLedgerId: "", + currentMembers: [] as LedgerMember[], loaded: false, }), getters: { currentLedger(state) { return state.ledgers.find((ledger) => ledger.id === state.currentLedgerId) ?? null; }, + currentRole(): "owner" | "member" | null { + return this.currentLedger?.role ?? null; + }, }, actions: { async loadLedgers() { if (this.loaded) return; if (ledgerLoadPromise) return ledgerLoadPromise; ledgerLoadPromise = (async () => { - if ((await db.ledgers.count()) === 0) await db.ledgers.bulkAdd(createDefaultLedgers()); - this.ledgers = (await db.ledgers.orderBy("updatedAt").reverse().toArray()).filter( - (ledger) => ledger.archivedAt === null, - ); + const auth = useAuthStore(); + if (!auth.user) return; + const result = await apiRequest<{ ledgers: LedgerRecord[] }>("/api/ledgers"); + const serverLedgers = result.ledgers.map((ledger) => ({ ...ledger })); + this.ledgers = serverLedgers; - const preference = await db.userPreferences.get(currentLedgerPreferenceId); + const allowedIds = new Set(this.ledgers.map((ledger) => ledger.id)); + const inaccessibleEntries = await db.entries.filter((entry) => !allowedIds.has(entry.ledgerId)).primaryKeys(); + const inaccessibleOperations = await db.syncOperations.filter((item) => !allowedIds.has(item.ledgerId)).primaryKeys(); + await db.transaction("rw", db.ledgers, db.entries, db.syncOperations, async () => { + await db.ledgers.clear(); + if (serverLedgers.length) await db.ledgers.bulkPut(serverLedgers); + if (inaccessibleEntries.length) await db.entries.bulkDelete(inaccessibleEntries); + if (inaccessibleOperations.length) await db.syncOperations.bulkDelete(inaccessibleOperations); + }); + + const preferenceId = currentLedgerPreferenceId(auth.user.id); + const preference = await db.userPreferences.get(preferenceId); const preferredLedger = this.ledgers.find((ledger) => ledger.id === preference?.value); - const fallbackLedger = this.ledgers.find((ledger) => ledger.id === "local-ledger") ?? this.ledgers[0]; - this.currentLedgerId = preferredLedger?.id ?? fallbackLedger?.id ?? ""; + this.currentLedgerId = preferredLedger?.id ?? this.ledgers[0]?.id ?? ""; if (this.currentLedgerId !== preference?.value) await this.persistCurrentLedger(); this.loaded = true; + await this.loadCurrentMembers(); })(); try { await ledgerLoadPromise; @@ -44,36 +63,47 @@ export const useLedgerStore = defineStore("ledgers", { ledgerLoadPromise = null; } }, + async reloadLedgers() { + this.loaded = false; + this.currentMembers = []; + await this.loadLedgers(); + }, async setCurrentLedger(ledgerId: string) { const ledger = this.ledgers.find((item) => item.id === ledgerId && item.archivedAt === null); if (!ledger) throw new Error("Ledger not found"); this.currentLedgerId = ledger.id; await this.persistCurrentLedger(); + await this.loadCurrentMembers(); }, async persistCurrentLedger() { - if (!this.currentLedgerId) return; + const auth = useAuthStore(); + if (!this.currentLedgerId || !auth.user) return; const preference: UserPreference = { - id: currentLedgerPreferenceId, - userId: localUserId, + id: currentLedgerPreferenceId(auth.user.id), + userId: auth.user.id, key: "currentLedgerId", value: this.currentLedgerId, updatedAt: new Date().toISOString(), }; await db.userPreferences.put(preference); }, + async loadCurrentMembers() { + if (!this.currentLedgerId) { + this.currentMembers = []; + return; + } + const result = await apiRequest<{ members: LedgerMember[] }>( + `/api/ledgers/${encodeURIComponent(this.currentLedgerId)}/members`, + ); + this.currentMembers = result.members; + }, + memberName(userId: string) { + return this.currentMembers.find((member) => member.id === userId)?.name ?? "历史用户"; + }, async updateLedger(input: Pick) { - const existing = await db.ledgers.get(input.id); - if (!existing) throw new Error("Ledger not found"); - const updated: LedgerRecord = { - ...existing, - ...input, - name: input.name.trim(), - updatedAt: new Date().toISOString(), - }; - await db.ledgers.put(updated); - this.loaded = false; - await this.loadLedgers(); - return updated; + await apiRequest(`/api/ledgers/${encodeURIComponent(input.id)}`, { method: "PATCH", body: input }); + await this.reloadLedgers(); + return this.ledgers.find((ledger) => ledger.id === input.id)!; }, }, }); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2db8581..3077441 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -134,6 +134,11 @@ input:focus-visible { background: #3478e5; } +.participant-avatar.member, +.share-member-avatar.member { + background: #3478e5; +} + .participant-avatar.member-li, .share-member-avatar.member-li { background: #7559d9; diff --git a/apps/web/src/views/EntryDetailView.vue b/apps/web/src/views/EntryDetailView.vue index c367955..c285ff4 100644 --- a/apps/web/src/views/EntryDetailView.vue +++ b/apps/web/src/views/EntryDetailView.vue @@ -272,7 +272,7 @@ async function deleteEntry() { -

由小王创建 · {{ createdAtLabel }}

+

由{{ ledgerStore.memberName(entry.createdBy) }}创建 · {{ createdAtLabel }}

diff --git a/apps/web/src/views/InviteView.vue b/apps/web/src/views/InviteView.vue new file mode 100644 index 0000000..df3fc87 --- /dev/null +++ b/apps/web/src/views/InviteView.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/apps/web/src/views/JoinLedgerView.vue b/apps/web/src/views/JoinLedgerView.vue new file mode 100644 index 0000000..5bb5213 --- /dev/null +++ b/apps/web/src/views/JoinLedgerView.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/apps/web/src/views/LedgerSettingsView.vue b/apps/web/src/views/LedgerSettingsView.vue index c65c662..afd7dfd 100644 --- a/apps/web/src/views/LedgerSettingsView.vue +++ b/apps/web/src/views/LedgerSettingsView.vue @@ -14,6 +14,10 @@ const saving = ref(false); onMounted(async () => { await ledgerStore.loadLedgers(); + if (ledgerStore.currentRole !== "owner") { + await router.replace("/"); + return; + } if (!ledgerStore.currentLedger) return; name.value = ledgerStore.currentLedger.name; color.value = ledgerStore.currentLedger.color; diff --git a/apps/web/src/views/LedgerView.vue b/apps/web/src/views/LedgerView.vue index e2cd836..a99116a 100644 --- a/apps/web/src/views/LedgerView.vue +++ b/apps/web/src/views/LedgerView.vue @@ -15,6 +15,7 @@ import { import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue"; import { useRouter } from "vue-router"; import QuickEntryHost from "../components/QuickEntryHost.vue"; +import { apiRequest, ApiError } from "../data/api"; import { findCategory, findCategoryPath } from "../data/categories"; import { useEntryStore } from "../stores/entries"; import { useLedgerStore } from "../stores/ledgers"; @@ -27,6 +28,8 @@ const moreMenuOpen = ref(false); const participantOpen = ref(false); const shareOpen = ref(false); const shareFeedback = ref(""); +const invitationLink = ref(""); +const generatingInvitation = ref(false); const visibleCount = ref(ENTRY_PAGE_SIZE); const ledgerContent = ref(null); const loadMoreTrigger = ref(null); @@ -155,12 +158,14 @@ function categoryLabel(entry: LedgerEntry) { function openParticipantSheet() { participantOpen.value = true; + void ledgerStore.loadCurrentMembers(); } -function openShareSheet() { +async function openShareSheet() { moreMenuOpen.value = false; - shareFeedback.value = ""; + shareFeedback.value = "正在生成邀请链接"; shareOpen.value = true; + await ensureInvitation(); } function openLedgerSettings() { @@ -168,14 +173,27 @@ function openLedgerSettings() { router.push("/ledger/settings"); } -function invitationUrl() { - const url = new URL(window.location.origin); - url.searchParams.set("invite", ledgerStore.currentLedgerId); - return url.toString(); +async function ensureInvitation() { + if (invitationLink.value || generatingInvitation.value || !ledgerStore.currentLedgerId) return invitationLink.value; + generatingInvitation.value = true; + try { + const result = await apiRequest<{ invitation: { url: string; expiresAt: string } }>( + `/api/ledgers/${encodeURIComponent(ledgerStore.currentLedgerId)}/invitations`, + { method: "POST" }, + ); + invitationLink.value = result.invitation.url; + shareFeedback.value = "邀请链接将在 7 天后失效"; + } catch (error) { + shareFeedback.value = error instanceof ApiError ? error.message : "邀请链接生成失败"; + } finally { + generatingInvitation.value = false; + } + return invitationLink.value; } async function copyInvitation() { - const link = invitationUrl(); + const link = await ensureInvitation(); + if (!link) return; if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(link); else { const input = document.createElement("textarea"); @@ -189,10 +207,12 @@ async function copyInvitation() { } async function shareLedger() { + const url = await ensureInvitation(); + if (!url) return; const data = { title: ledgerStore.currentLedger?.name ?? "账本", text: `邀请你加入我的${ledgerStore.currentLedger?.name ?? "账本"}`, - url: invitationUrl(), + url, }; if (!navigator.share) { await copyInvitation(); @@ -214,15 +234,15 @@ async function shareLedger() {
-
@@ -296,7 +316,7 @@ async function shareLedger() {
{{ entry.note || categoryLabel(entry) }}
@@ -329,23 +349,15 @@ async function shareLedger() {
{{ ledgerStore.currentLedger?.name ?? "账本" }} - 3 位参与者 + {{ ledgerStore.currentMembers.length }} 位参与者
diff --git a/apps/web/src/views/LoginView.vue b/apps/web/src/views/LoginView.vue new file mode 100644 index 0000000..e251a1a --- /dev/null +++ b/apps/web/src/views/LoginView.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/apps/web/src/views/MeView.vue b/apps/web/src/views/MeView.vue index dcd81ff..a81371e 100644 --- a/apps/web/src/views/MeView.vue +++ b/apps/web/src/views/MeView.vue @@ -1,13 +1,54 @@