mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(auth): add better-auth username plugin (#279)
* feat(auth): add better-auth username plugin Enable username-based registration and sign-in by adding the username plugin to both server and client auth configurations. Adds username and display_username columns to the user table via migration. Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(auth): add username plugin tests and fix test setup Add schema and integration tests for the username plugin. Fix the in-memory SQLite test setup to include username columns so existing auth tests don't break. Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
-- Add username columns for better-auth username plugin
|
||||
ALTER TABLE `user` ADD `username` text;--> statement-breakpoint
|
||||
ALTER TABLE `user` ADD `display_username` text;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1775090000000,
|
||||
"tag": "0003_recycle_bin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1775100000000,
|
||||
"tag": "0004_username_plugin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { admin, organization } from 'better-auth/plugins'
|
||||
import { admin, organization, username } from 'better-auth/plugins'
|
||||
import { count, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import * as authSchema from './db/auth-schema'
|
||||
@@ -49,7 +49,7 @@ export function createAuth(db: Database, secret: string, baseURL?: string, trust
|
||||
maxAge: 60 * 5,
|
||||
},
|
||||
},
|
||||
plugins: [admin(), organization()],
|
||||
plugins: [admin(), organization(), username()],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { user } from './auth-schema.js'
|
||||
|
||||
describe('auth-schema user table', () => {
|
||||
it('has a username column', () => {
|
||||
expect(user.username).toBeDefined()
|
||||
})
|
||||
|
||||
it('username column maps to the "username" SQL column name', () => {
|
||||
expect(user.username.name).toBe('username')
|
||||
})
|
||||
|
||||
it('username column is text type', () => {
|
||||
expect(user.username.columnType).toBe('SQLiteText')
|
||||
})
|
||||
|
||||
it('username column has a unique constraint', () => {
|
||||
expect(user.username.isUnique).toBe(true)
|
||||
})
|
||||
|
||||
it('username column is nullable (no notNull)', () => {
|
||||
expect(user.username.notNull).toBe(false)
|
||||
})
|
||||
|
||||
it('has a displayUsername column', () => {
|
||||
expect(user.displayUsername).toBeDefined()
|
||||
})
|
||||
|
||||
it('displayUsername column maps to the "display_username" SQL column name', () => {
|
||||
expect(user.displayUsername.name).toBe('display_username')
|
||||
})
|
||||
|
||||
it('displayUsername column is text type', () => {
|
||||
expect(user.displayUsername.columnType).toBe('SQLiteText')
|
||||
})
|
||||
|
||||
it('displayUsername column has no unique constraint', () => {
|
||||
expect(user.displayUsername.isUnique).toBeFalsy()
|
||||
})
|
||||
|
||||
it('displayUsername column is nullable (no notNull)', () => {
|
||||
expect(user.displayUsername.notNull).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,8 @@ export const user = sqliteTable('user', {
|
||||
banned: integer('banned', { mode: 'boolean' }).default(false),
|
||||
banReason: text('ban_reason'),
|
||||
banExpires: integer('ban_expires', { mode: 'timestamp_ms' }),
|
||||
username: text('username').unique(),
|
||||
displayUsername: text('display_username'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
|
||||
describe('migration 0004_username_plugin.sql', () => {
|
||||
const migrationPath = join(process.cwd(), 'migrations/0004_username_plugin.sql')
|
||||
const sql = readFileSync(migrationPath, 'utf-8')
|
||||
|
||||
it('adds the username column to the user table', () => {
|
||||
expect(sql).toMatch(/ALTER TABLE.*`user`.*ADD.*`username`.*text/i)
|
||||
})
|
||||
|
||||
it('adds the display_username column to the user table', () => {
|
||||
expect(sql).toMatch(/ALTER TABLE.*`user`.*ADD.*`display_username`.*text/i)
|
||||
})
|
||||
|
||||
it('creates a unique index on the username column', () => {
|
||||
expect(sql).toMatch(/CREATE UNIQUE INDEX.*`user_username_unique`.*ON.*`user`.*\(`username`\)/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('username plugin — sign-up with username', () => {
|
||||
it('sign-up with username stores the username on the user record', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
password: 'password123456',
|
||||
username: 'alice42',
|
||||
}),
|
||||
})
|
||||
const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'alice@example.com'))
|
||||
expect(users[0].username).toBe('alice42')
|
||||
})
|
||||
|
||||
it('sign-up without username leaves the username column null', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Bob', email: 'bob@example.com', password: 'password123456' }),
|
||||
})
|
||||
const users = await db.select().from(authSchema.user).where(eq(authSchema.user.email, 'bob@example.com'))
|
||||
expect(users[0].username).toBeNull()
|
||||
})
|
||||
|
||||
it('sign-up with duplicate username returns a non-200 response', async () => {
|
||||
const { app } = createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'First',
|
||||
email: 'first@example.com',
|
||||
password: 'password123456',
|
||||
username: 'shared_handle',
|
||||
}),
|
||||
})
|
||||
const res = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Second',
|
||||
email: 'second@example.com',
|
||||
password: 'password123456',
|
||||
username: 'shared_handle',
|
||||
}),
|
||||
})
|
||||
expect(res.status).not.toBe(200)
|
||||
})
|
||||
|
||||
it('two users with different usernames both register successfully', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const res1 = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'User1', email: 'u1@example.com', password: 'password123456', username: 'user1' }),
|
||||
})
|
||||
const res2 = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'User2', email: 'u2@example.com', password: 'password123456', username: 'user2' }),
|
||||
})
|
||||
expect(res1.status).toBe(200)
|
||||
expect(res2.status).toBe(200)
|
||||
const allUsers = await db.select().from(authSchema.user)
|
||||
const usernames = allUsers.map((u) => u.username)
|
||||
expect(usernames).toContain('user1')
|
||||
expect(usernames).toContain('user2')
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@ const AUTH_SCHEMA_SQL = `
|
||||
banned INTEGER DEFAULT 0,
|
||||
ban_reason TEXT,
|
||||
ban_expires INTEGER,
|
||||
username TEXT UNIQUE,
|
||||
display_username TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { usernameClient } from 'better-auth/client/plugins'
|
||||
import { createAuthClient } from 'better-auth/react'
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL || '',
|
||||
plugins: [usernameClient()],
|
||||
})
|
||||
|
||||
export const { signIn, signUp, signOut, useSession } = authClient
|
||||
|
||||
Reference in New Issue
Block a user