diff --git a/biome.json b/biome.json index 0a50ab2..b238564 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,8 @@ "build/**", "src/components/ui/*.tsx", "src/components/magicui/*.tsx", - "tailwind.config.ts" + "tailwind.config.ts", + "src/db/schema.ts" ] }, "formatter": { @@ -52,7 +53,8 @@ "build/**", "src/components/ui/*.tsx", "src/components/magicui/*.tsx", - "tailwind.config.ts" + "tailwind.config.ts", + "src/db/schema.ts" ] }, "javascript": { diff --git a/drizzle/0002_talented_greymalkin.sql b/drizzle/0002_talented_greymalkin.sql new file mode 100644 index 0000000..4775b8a --- /dev/null +++ b/drizzle/0002_talented_greymalkin.sql @@ -0,0 +1,2 @@ +ALTER TABLE "user" ADD COLUMN "username" text;--> statement-breakpoint +ALTER TABLE "user" ADD CONSTRAINT "user_username_unique" UNIQUE("username"); \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..fefbb3f --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,362 @@ +{ + "id": "21c4862e-1256-46f8-ac51-312d90aa61f8", + "prevId": "53387f51-ed0d-4c44-b8ed-5af936eef75e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9c8b329..63fdb8f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1740323860731, "tag": "0001_shocking_wild_pack", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1741971106943, + "tag": "0002_talented_greymalkin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/[locale]/(marketing)/(home)/page.tsx b/src/app/[locale]/(marketing)/(home)/page.tsx index 6e029b5..04fb3f7 100644 --- a/src/app/[locale]/(marketing)/(home)/page.tsx +++ b/src/app/[locale]/(marketing)/(home)/page.tsx @@ -26,7 +26,7 @@ export async function generateMetadata({ return constructMetadata({ title: t('title'), description: t('description'), - canonicalUrl: `${getBaseUrlWithLocale(locale)}/`, + canonicalUrl: `${getBaseUrlWithLocale(locale)}`, }); } diff --git a/src/db/schema.ts b/src/db/schema.ts index bc6f112..65b6a5d 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,62 +1,53 @@ -import { - pgTable, - text, - integer, - timestamp, - boolean, -} from 'drizzle-orm/pg-core'; +import { pgTable, text, integer, timestamp, boolean } from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').notNull(), + image: text('image'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + username: text('username').unique(), + role: text('role'), + banned: boolean('banned'), + banReason: text('ban_reason'), + banExpires: timestamp('ban_expires') + }); -export const user = pgTable('user', { - id: text('id').primaryKey(), - name: text('name').notNull(), - email: text('email').notNull().unique(), - emailVerified: boolean('email_verified').notNull(), - image: text('image'), - createdAt: timestamp('created_at').notNull(), - updatedAt: timestamp('updated_at').notNull(), - role: text('role'), - banned: boolean('banned'), - banReason: text('ban_reason'), - banExpires: timestamp('ban_expires'), -}); +export const session = pgTable("session", { + id: text("id").primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }), + impersonatedBy: text('impersonated_by') + }); -export const session = pgTable('session', { - id: text('id').primaryKey(), - expiresAt: timestamp('expires_at').notNull(), - token: text('token').notNull().unique(), - createdAt: timestamp('created_at').notNull(), - updatedAt: timestamp('updated_at').notNull(), - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - impersonatedBy: text('impersonated_by'), -}); +export const account = pgTable("account", { + id: text("id").primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id').notNull().references(()=> user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull() + }); -export const account = pgTable('account', { - id: text('id').primaryKey(), - accountId: text('account_id').notNull(), - providerId: text('provider_id').notNull(), - userId: text('user_id') - .notNull() - .references(() => user.id, { onDelete: 'cascade' }), - accessToken: text('access_token'), - refreshToken: text('refresh_token'), - idToken: text('id_token'), - accessTokenExpiresAt: timestamp('access_token_expires_at'), - refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), - scope: text('scope'), - password: text('password'), - createdAt: timestamp('created_at').notNull(), - updatedAt: timestamp('updated_at').notNull(), -}); - -export const verification = pgTable('verification', { - id: text('id').primaryKey(), - identifier: text('identifier').notNull(), - value: text('value').notNull(), - expiresAt: timestamp('expires_at').notNull(), - createdAt: timestamp('created_at'), - updatedAt: timestamp('updated_at'), -}); +export const verification = pgTable("verification", { + id: text("id").primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at') + }); diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 9e7365e..bfabbb8 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,5 +1,5 @@ import { createAuthClient } from 'better-auth/react'; -import { adminClient } from 'better-auth/client/plugins'; +import { adminClient, usernameClient } from 'better-auth/client/plugins'; import { getBaseUrl } from './urls/get-base-url'; /** @@ -8,6 +8,8 @@ import { getBaseUrl } from './urls/get-base-url'; export const authClient = createAuthClient({ baseURL: getBaseUrl(), plugins: [ + // https://www.better-auth.com/docs/plugins/username + usernameClient(), // https://www.better-auth.com/docs/plugins/admin#add-the-client-plugin adminClient(), ], diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 937e4a5..851bdb4 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,24 +1,22 @@ -import { getWebsiteInfo } from '@/config'; import db from '@/db/index'; import { account, session, user, verification } from '@/db/schema'; -import { createTranslator } from '@/i18n/translator'; import { getLocaleFromRequest } from '@/lib/utils'; import { send } from '@/mail'; import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { admin } from 'better-auth/plugins'; - -// Create a simple translator function for default values -const t = createTranslator((key: string) => key); -const websiteInfo = getWebsiteInfo(t); +import { admin, username } from 'better-auth/plugins'; +/** + * https://www.better-auth.com/docs/reference/options + */ export const auth = betterAuth({ - appName: websiteInfo.name, + appName: 'MkSaaS', database: drizzleAdapter(db, { provider: 'pg', // or "mysql", "sqlite" // The schema object that defines the tables and fields // [BetterAuthError]: [# Drizzle Adapter]: The model "verification" was not found in the schema object. // Please pass the schema directly to the adapter options. + // https://www.better-auth.com/docs/adapters/drizzle#additional-information schema: { user: user, session: session, @@ -30,12 +28,13 @@ export const auth = betterAuth({ // https://www.better-auth.com/docs/concepts/session-management#cookie-cache cookieCache: { enabled: true, - maxAge: 5 * 60, // Cache duration in seconds + maxAge: 60 * 60, // Cache duration in seconds }, // https://www.better-auth.com/docs/concepts/session-management#session-expiration expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, - freshAge: 60 * 5, + // https://www.better-auth.com/docs/concepts/session-management#session-freshness + freshAge: 60 * 60 * 24, }, emailAndPassword: { enabled: true, @@ -44,7 +43,6 @@ export const auth = betterAuth({ // https://www.better-auth.com/docs/authentication/email-password#forget-password async sendResetPassword({ user, url }, request) { const locale = getLocaleFromRequest(request); - console.log('sendResetPassword, locale:', locale); await send({ to: user.email, template: 'forgotPassword', @@ -62,7 +60,6 @@ export const auth = betterAuth({ // https://www.better-auth.com/docs/authentication/email-password#require-email-verification sendVerificationEmail: async ({ user, url, token }, request) => { const locale = getLocaleFromRequest(request); - console.log('sendVerificationEmail, locale:', locale); await send({ to: user.email, template: 'verifyEmail', @@ -93,8 +90,26 @@ export const auth = betterAuth({ trustedProviders: ['google', 'github'], }, }, + user: { + // https://www.better-auth.com/docs/concepts/users-accounts#delete-user + deleteUser: { + enabled: true, + }, + }, plugins: [ + // https://www.better-auth.com/docs/plugins/username + username({ + minUsernameLength: 3, + maxUsernameLength: 30, + }), // https://www.better-auth.com/docs/plugins/admin admin(), ], + onAPIError: { + // https://www.better-auth.com/docs/reference/options#onapierror + errorURL: '/auth/error', + onError: (error, ctx) => { + console.error('auth error:', error); + }, + }, });