feat(ledger): add bookkeeping workbench and transaction views
- Add transactions_dirty table and zero-tolerance cleansing action - Implement bookkeeping workbench (/bookkeeping) with searchable Combobox channel selector and card suffix / account identifier - Implement transaction ledger (/transactions) with date groupings, multi-currency metrics, and filters - Update AGENTS.md guidelines for Base UI Select and Combobox bindings
This commit is contained in:
+13
-13
@@ -1,4 +1,4 @@
|
||||
import type { NextAuthConfig } from "next-auth";
|
||||
import type { NextAuthConfig } from "next-auth"
|
||||
|
||||
export const authConfig = {
|
||||
pages: {
|
||||
@@ -7,37 +7,37 @@ export const authConfig = {
|
||||
},
|
||||
callbacks: {
|
||||
authorized({ auth, request: { nextUrl } }) {
|
||||
const isLoggedIn = !!auth?.user;
|
||||
const pathname = nextUrl.pathname;
|
||||
const publicPaths = ["/login", "/register", "/api/auth"];
|
||||
const isLoggedIn = !!auth?.user
|
||||
const pathname = nextUrl.pathname
|
||||
const publicPaths = ["/login", "/register", "/api/auth"]
|
||||
const isPublic = publicPaths.some(
|
||||
(path) => pathname === path || pathname.startsWith("/api/auth/")
|
||||
);
|
||||
)
|
||||
|
||||
// 已登录用户在登录/注册页时重定向到首页
|
||||
if (isLoggedIn && (pathname === "/login" || pathname === "/register")) {
|
||||
return Response.redirect(new URL("/", nextUrl));
|
||||
return Response.redirect(new URL("/", nextUrl))
|
||||
}
|
||||
|
||||
// 访问受保护页面必须已登录
|
||||
if (!isLoggedIn && !isPublic) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
jwt({ token, user }) {
|
||||
if (user?.id) {
|
||||
token.id = user.id;
|
||||
token.id = user.id
|
||||
}
|
||||
return token;
|
||||
return token
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user && token.id) {
|
||||
session.user.id = token.id as string;
|
||||
session.user.id = token.id as string
|
||||
}
|
||||
return session;
|
||||
return session
|
||||
},
|
||||
},
|
||||
providers: [],
|
||||
} satisfies NextAuthConfig;
|
||||
} satisfies NextAuthConfig
|
||||
|
||||
+34
-34
@@ -1,11 +1,11 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { users, userAccounts } from "@/lib/db/schema";
|
||||
import { verifyPassword } from "./password";
|
||||
import { authConfig } from "./config";
|
||||
import type { Provider } from "next-auth/providers";
|
||||
import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { users, userAccounts } from "@/lib/db/schema"
|
||||
import { verifyPassword } from "./password"
|
||||
import { authConfig } from "./config"
|
||||
import type { Provider } from "next-auth/providers"
|
||||
|
||||
// 动态构建 Providers 列表
|
||||
const providers: Provider[] = [
|
||||
@@ -17,25 +17,25 @@ const providers: Provider[] = [
|
||||
},
|
||||
async authorize(credentials) {
|
||||
if (!credentials?.email || !credentials?.password) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const email = String(credentials.email).toLowerCase().trim();
|
||||
const password = String(credentials.password);
|
||||
const email = String(credentials.email).toLowerCase().trim()
|
||||
const password = String(credentials.password)
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
.limit(1)
|
||||
|
||||
if (!user || !user.passwordHash || !user.isActive) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(password, user.passwordHash);
|
||||
const isValid = await verifyPassword(password, user.passwordHash)
|
||||
if (!isValid) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -43,16 +43,16 @@ const providers: Provider[] = [
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.avatar,
|
||||
};
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
]
|
||||
|
||||
// 如果配置了 OIDC,且 AUTH_OIDC_ENABLED 为 true,则动态注册通用 OIDC 提供商
|
||||
const isOidcEnabled =
|
||||
process.env.AUTH_OIDC_ENABLED === "true" &&
|
||||
Boolean(process.env.AUTH_OIDC_ISSUER) &&
|
||||
Boolean(process.env.AUTH_OIDC_CLIENT_ID);
|
||||
Boolean(process.env.AUTH_OIDC_CLIENT_ID)
|
||||
|
||||
if (isOidcEnabled) {
|
||||
providers.push({
|
||||
@@ -72,7 +72,7 @@ if (isOidcEnabled) {
|
||||
scope: process.env.AUTH_OIDC_SCOPES || "openid profile email",
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
@@ -86,13 +86,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
...authConfig.callbacks,
|
||||
async signIn({ user, account }) {
|
||||
if (!account || account.type === "credentials") {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理 OIDC / OAuth 登录与本地用户的关联或新建
|
||||
const email = user.email?.toLowerCase().trim();
|
||||
const email = user.email?.toLowerCase().trim()
|
||||
if (!email) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 查询用户是否已存在
|
||||
@@ -100,7 +100,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, email))
|
||||
.limit(1);
|
||||
.limit(1)
|
||||
|
||||
if (!existingUser) {
|
||||
// 创建新用户
|
||||
@@ -112,20 +112,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
avatar: user.image || null,
|
||||
isActive: true,
|
||||
})
|
||||
.returning();
|
||||
existingUser = newUser;
|
||||
.returning()
|
||||
existingUser = newUser
|
||||
}
|
||||
|
||||
user.id = existingUser.id;
|
||||
user.id = existingUser.id
|
||||
|
||||
// 2. 查询是否已记录该 provider 的账户绑定
|
||||
const [existingAccount] = await db
|
||||
.select()
|
||||
.from(userAccounts)
|
||||
.where(
|
||||
eq(userAccounts.providerAccountId, account.providerAccountId)
|
||||
)
|
||||
.limit(1);
|
||||
.where(eq(userAccounts.providerAccountId, account.providerAccountId))
|
||||
.limit(1)
|
||||
|
||||
if (!existingAccount) {
|
||||
await db.insert(userAccounts).values({
|
||||
@@ -134,14 +132,16 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||
providerAccountId: account.providerAccountId,
|
||||
refreshToken: account.refresh_token,
|
||||
accessToken: account.access_token,
|
||||
expiresAt: account.expires_at ? new Date(account.expires_at * 1000) : null,
|
||||
expiresAt: account.expires_at
|
||||
? new Date(account.expires_at * 1000)
|
||||
: null,
|
||||
tokenType: account.token_type,
|
||||
scope: account.scope,
|
||||
idToken: account.id_token,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import { hash, verify } from "@node-rs/argon2"
|
||||
|
||||
// 遵循 OWASP 密码哈希安全推荐配置
|
||||
const ARGON2_OPTIONS = {
|
||||
@@ -6,16 +6,19 @@ const ARGON2_OPTIONS = {
|
||||
timeCost: 2,
|
||||
outputLen: 32,
|
||||
parallelism: 1,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await hash(password, ARGON2_OPTIONS);
|
||||
return await hash(password, ARGON2_OPTIONS)
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, passwordHash: string): Promise<boolean> {
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
passwordHash: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(passwordHash, password);
|
||||
return await verify(passwordHash, password)
|
||||
} catch {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user