Added Better Auth

This commit is contained in:
Arunavo Ray
2025-07-10 23:15:37 +05:30
parent 46cf117bdf
commit b838310872
34 changed files with 2573 additions and 175 deletions

View File

@@ -4,6 +4,7 @@ import * as React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { useAuth } from '@/hooks/useAuth';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
@@ -11,43 +12,29 @@ import { showErrorToast } from '@/lib/utils';
export function LoginForm() {
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
async function handleLogin(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const form = e.currentTarget;
const formData = new FormData(form);
const username = formData.get('username') as string | null;
const email = formData.get('email') as string | null;
const password = formData.get('password') as string | null;
if (!username || !password) {
toast.error('Please enter both username and password');
if (!email || !password) {
toast.error('Please enter both email and password');
setIsLoading(false);
return;
}
const loginData = { username, password };
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData),
});
const data = await response.json();
if (response.ok) {
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
showErrorToast(data.error || 'Login failed. Please try again.', toast);
}
await login(email, password);
toast.success('Login successful!');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/dashboard';
}, 1000);
} catch (error) {
showErrorToast(error, toast);
} finally {
@@ -80,16 +67,16 @@ export function LoginForm() {
<form id="login-form" onSubmit={handleLogin}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1">
Username
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="username"
name="username"
type="text"
id="email"
name="email"
type="email"
required
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="Enter your username"
placeholder="Enter your email"
disabled={isLoading}
/>
</div>

View File

@@ -5,9 +5,11 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { toast, Toaster } from 'sonner';
import { showErrorToast } from '@/lib/utils';
import { useAuth } from '@/hooks/useAuth';
export function SignupForm() {
const [isLoading, setIsLoading] = useState(false);
const { register } = useAuth();
async function handleSignup(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -31,28 +33,13 @@ export function SignupForm() {
return;
}
const signupData = { username, email, password };
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signupData),
});
const data = await response.json();
if (response.ok) {
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/';
}, 1500);
} else {
showErrorToast(data.error || 'Failed to create account. Please try again.', toast);
}
await register(username, email, password);
toast.success('Account created successfully! Redirecting to dashboard...');
// Small delay before redirecting to see the success message
setTimeout(() => {
window.location.href = '/dashboard';
}, 1500);
} catch (error) {
showErrorToast(error, toast);
} finally {

147
src/hooks/useAuth-legacy.ts Normal file
View File

@@ -0,0 +1,147 @@
import * as React from "react";
import {
useState,
useEffect,
createContext,
useContext,
type Context,
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
interface AuthContextType {
user: ExtendedUser | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
AuthContextType | undefined
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Function to refetch the user data
const refreshUser = async () => {
// not using loading state to keep the ui seamless and refresh the data in bg
// setIsLoading(true);
try {
const user = await authApi.getCurrentUser();
setUser(user);
} catch (err: any) {
setUser(null);
console.error("Failed to refresh user data", err);
} finally {
// setIsLoading(false);
}
};
// Automatically check the user status when the app loads
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authApi.getCurrentUser();
console.log("User data fetched:", user);
setUser(user);
} catch (err: any) {
setUser(null);
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
} else {
window.location.href = "/login";
}
console.error("Auth check failed", err);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
const login = async (username: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.login(username, password);
setUser(user);
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
throw err;
} finally {
setIsLoading(false);
}
};
const register = async (
username: string,
email: string,
password: string
) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.register(username, email, password);
setUser(user);
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
throw err;
} finally {
setIsLoading(false);
}
};
const logout = async () => {
setIsLoading(true);
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
} catch (err) {
console.error("Logout error:", err);
} finally {
setIsLoading(false);
}
};
// Create the context value with the added refreshUser function
const contextValue = {
user,
isLoading,
error,
login,
register,
logout,
refreshUser,
};
// Return the provider with the context value
return React.createElement(
AuthContext.Provider,
{ value: contextValue },
children
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -6,21 +6,22 @@ import {
useContext,
type Context,
} from "react";
import { authApi } from "@/lib/api";
import type { ExtendedUser } from "@/types/user";
import { authClient, useSession as useBetterAuthSession } from "@/lib/auth-client";
import type { Session, AuthUser } from "@/lib/auth-client";
interface AuthContextType {
user: ExtendedUser | null;
user: AuthUser | null;
session: Session | null;
isLoading: boolean;
error: string | null;
login: (username: string, password: string) => Promise<void>;
login: (email: string, password: string, username?: string) => Promise<void>;
register: (
username: string,
email: string,
password: string
) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Added refreshUser function
refreshUser: () => Promise<void>;
}
const AuthContext: Context<AuthContextType | undefined> = createContext<
@@ -28,60 +29,53 @@ const AuthContext: Context<AuthContextType | undefined> = createContext<
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<ExtendedUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const betterAuthSession = useBetterAuthSession();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Function to refetch the user data
const refreshUser = async () => {
// not using loading state to keep the ui seamless and refresh the data in bg
// setIsLoading(true);
try {
const user = await authApi.getCurrentUser();
setUser(user);
} catch (err: any) {
setUser(null);
console.error("Failed to refresh user data", err);
} finally {
// setIsLoading(false);
}
};
// Derive user and session from Better Auth hook
const user = betterAuthSession.data?.user || null;
const session = betterAuthSession.data || null;
// Automatically check the user status when the app loads
// Check if this is first load and redirect if needed
useEffect(() => {
const checkAuth = async () => {
try {
const user = await authApi.getCurrentUser();
console.log("User data fetched:", user);
setUser(user);
} catch (err: any) {
setUser(null);
// Redirect user based on error
if (err?.message === "No users found") {
window.location.href = "/signup";
} else {
window.location.href = "/login";
const checkFirstUser = async () => {
if (!betterAuthSession.isPending && !user) {
try {
// Check if there are any users in the system
const response = await fetch("/api/auth/check-users");
if (response.status === 404) {
// No users found, redirect to signup
window.location.href = "/signup";
} else if (!window.location.pathname.includes("/login")) {
// User not authenticated, redirect to login
window.location.href = "/login";
}
} catch (err) {
console.error("Failed to check users:", err);
}
console.error("Auth check failed", err);
} finally {
setIsLoading(false);
}
};
checkAuth();
}, []);
checkFirstUser();
}, [betterAuthSession.isPending, user]);
const login = async (username: string, password: string) => {
const login = async (email: string, password: string) => {
setIsLoading(true);
setError(null);
try {
const user = await authApi.login(username, password);
setUser(user);
const result = await authClient.signIn.email({
email,
password,
callbackURL: "/dashboard",
});
if (result.error) {
throw new Error(result.error.message || "Login failed");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
const message = err instanceof Error ? err.message : "Login failed";
setError(message);
throw err;
} finally {
setIsLoading(false);
@@ -96,10 +90,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setIsLoading(true);
setError(null);
try {
const user = await authApi.register(username, email, password);
setUser(user);
const result = await authClient.signUp.email({
email,
password,
name: username, // Better Auth uses 'name' field
username, // Also pass username as additional field
callbackURL: "/dashboard",
});
if (result.error) {
throw new Error(result.error.message || "Registration failed");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
const message = err instanceof Error ? err.message : "Registration failed";
setError(message);
throw err;
} finally {
setIsLoading(false);
@@ -109,9 +113,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const logout = async () => {
setIsLoading(true);
try {
await authApi.logout();
setUser(null);
window.location.href = "/login";
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
window.location.href = "/login";
},
},
});
} catch (err) {
console.error("Logout error:", err);
} finally {
@@ -119,10 +127,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
};
// Create the context value with the added refreshUser function
const refreshUser = async () => {
// Better Auth automatically handles session refresh
// We can force a refetch if needed
await betterAuthSession.refetch();
};
// Create the context value
const contextValue = {
user,
isLoading,
user: user as AuthUser | null,
session,
isLoading: isLoading || betterAuthSession.isPending,
error,
login,
register,
@@ -145,3 +160,6 @@ export function useAuth() {
}
return context;
}
// Export the Better Auth session hook for direct use when needed
export { useBetterAuthSession };

22
src/lib/auth-client.ts Normal file
View File

@@ -0,0 +1,22 @@
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// The base URL is optional when running on the same domain
// Better Auth will use the current domain by default
});
// Export commonly used methods for convenience
export const {
signIn,
signUp,
signOut,
useSession,
sendVerificationEmail,
resetPassword,
requestPasswordReset,
getSession
} = authClient;
// Export types
export type Session = Awaited<ReturnType<typeof authClient.getSession>>["data"];
export type AuthUser = Session extends { user: infer U } ? U : never;

76
src/lib/auth-config.ts Normal file
View File

@@ -0,0 +1,76 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { sso, oidcProvider } from "better-auth/plugins";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
// Generate or use existing JWT secret
const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET or BETTER_AUTH_SECRET environment variable is required");
}
// This function will be called with the actual database instance
export function createAuth(db: BunSQLiteDatabase) {
return betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true, // Our tables use plural names (users, not user)
}),
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Authentication methods
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // We'll enable this later
sendResetPassword: async ({ user, url, token }, request) => {
// TODO: Implement email sending for password reset
console.log("Password reset requested for:", user.email);
console.log("Reset URL:", url);
},
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration
user: {
additionalFields: {
// We can add custom fields here if needed
},
},
// Plugins for future OIDC/SSO support
plugins: [
// SSO plugin for OIDC client support
sso({
provisionUser: async (data) => {
// Custom user provisioning logic for SSO users
console.log("Provisioning SSO user:", data);
return data;
},
}),
// OIDC Provider plugin (for future use when we want to be an OIDC provider)
oidcProvider({
loginPage: "/signin",
consentPage: "/oauth/consent",
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
],
});
}

View File

@@ -0,0 +1,179 @@
/**
* Example OIDC/SSO Configuration for Better Auth
*
* This file demonstrates how to enable OIDC and SSO features in Gitea Mirror.
* To use: Copy this file to auth-oidc-config.ts and update the auth.ts import.
*/
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { sso } from "better-auth/plugins/sso";
import { oidcProvider } from "better-auth/plugins/oidc";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
export function createAuthWithOIDC(db: BunSQLiteDatabase) {
return betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true,
}),
// Base configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
basePath: "/api/auth",
// Email/Password authentication
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration with additional fields
user: {
additionalFields: {
username: {
type: "string",
required: true,
defaultValue: "user",
input: true,
}
},
},
// OAuth2 providers (examples)
socialProviders: {
github: {
enabled: !!process.env.GITHUB_OAUTH_CLIENT_ID,
clientId: process.env.GITHUB_OAUTH_CLIENT_ID!,
clientSecret: process.env.GITHUB_OAUTH_CLIENT_SECRET!,
},
google: {
enabled: !!process.env.GOOGLE_OAUTH_CLIENT_ID,
clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
},
},
// Plugins
plugins: [
// SSO Plugin - For OIDC/SAML client functionality
sso({
// Auto-provision users from SSO providers
provisionUser: async (data) => {
console.log("Provisioning SSO user:", data.email);
// Custom logic to set username from email
const username = data.email.split('@')[0];
return {
...data,
username,
};
},
// Organization provisioning for enterprise SSO
organizationProvisioning: {
disabled: false,
defaultRole: "member",
getRole: async (user) => {
// Custom logic to determine user role
// For admin emails, grant admin role
if (user.email?.endsWith('@admin.example.com')) {
return 'admin';
}
return 'member';
},
},
}),
// OIDC Provider Plugin - Makes Gitea Mirror an OIDC provider
oidcProvider({
// Login page for OIDC authentication flow
loginPage: "/login",
// Consent page for OAuth2 authorization
consentPage: "/oauth/consent",
// Allow dynamic client registration
allowDynamicClientRegistration: false,
// OIDC metadata configuration
metadata: {
issuer: process.env.BETTER_AUTH_URL || "http://localhost:3000",
authorization_endpoint: "/api/auth/oauth2/authorize",
token_endpoint: "/api/auth/oauth2/token",
userinfo_endpoint: "/api/auth/oauth2/userinfo",
jwks_uri: "/api/auth/jwks",
},
// Additional user info claims
getAdditionalUserInfoClaim: (user, scopes) => {
const claims: Record<string, any> = {};
// Add custom claims based on scopes
if (scopes.includes('profile')) {
claims.username = user.username;
claims.preferred_username = user.username;
}
if (scopes.includes('gitea')) {
// Add Gitea-specific claims
claims.gitea_admin = false; // Customize based on your logic
claims.gitea_repos = []; // Could fetch user's repositories
}
return claims;
},
}),
],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
// Add your OIDC client domains here
],
});
}
// Environment variables needed:
/*
# OAuth2 Providers (optional)
GITHUB_OAUTH_CLIENT_ID=your-github-client-id
GITHUB_OAUTH_CLIENT_SECRET=your-github-client-secret
GOOGLE_OAUTH_CLIENT_ID=your-google-client-id
GOOGLE_OAUTH_CLIENT_SECRET=your-google-client-secret
# SSO Configuration (when registering providers)
SSO_PROVIDER_ISSUER=https://idp.example.com
SSO_PROVIDER_CLIENT_ID=your-client-id
SSO_PROVIDER_CLIENT_SECRET=your-client-secret
*/
// Example: Registering an SSO provider programmatically
/*
import { authClient } from "./auth-client";
// Register corporate SSO
await authClient.sso.register({
issuer: "https://login.microsoftonline.com/tenant-id/v2.0",
domain: "company.com",
clientId: process.env.AZURE_CLIENT_ID!,
clientSecret: process.env.AZURE_CLIENT_SECRET!,
providerId: "azure-ad",
mapping: {
id: "sub",
email: "email",
emailVerified: "email_verified",
name: "name",
image: "picture",
},
});
*/

64
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,64 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "./db";
// Generate or use existing JWT secret
const JWT_SECRET = process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET;
if (!JWT_SECRET) {
throw new Error("JWT_SECRET or BETTER_AUTH_SECRET environment variable is required");
}
export const auth = betterAuth({
// Database configuration
database: drizzleAdapter(db, {
provider: "sqlite",
usePlural: true, // Our tables use plural names (users, not user)
}),
// Base URL configuration
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3000",
basePath: "/api/auth", // Specify the base path for auth endpoints
// Authentication methods
emailAndPassword: {
enabled: true,
requireEmailVerification: false, // We'll enable this later
sendResetPassword: async ({ user, url, token }, request) => {
// TODO: Implement email sending for password reset
console.log("Password reset requested for:", user.email);
console.log("Reset URL:", url);
},
},
// Session configuration
session: {
cookieName: "better-auth-session",
updateSessionCookieAge: true,
expiresIn: 60 * 60 * 24 * 30, // 30 days
},
// User configuration
user: {
additionalFields: {
// Keep the username field from our existing schema
username: {
type: "string",
required: true,
defaultValue: "user", // Default for migration
input: true, // Allow in signup form
}
},
},
// TODO: Add plugins for SSO and OIDC support in the future
// plugins: [],
// Trusted origins for CORS
trustedOrigins: [
process.env.BETTER_AUTH_URL || "http://localhost:3000",
],
});
// Export type for use in other parts of the app
export type Auth = typeof auth;

View File

@@ -23,14 +23,14 @@ let sqlite: Database;
try {
sqlite = new Database(dbPath);
console.log("Successfully connected to SQLite database using Bun's native driver");
// Run Drizzle migrations if needed
runDrizzleMigrations();
} catch (error) {
console.error("Error opening database:", error);
throw error;
}
// Create drizzle instance with the SQLite client
export const db = drizzle({ client: sqlite });
/**
* Run Drizzle migrations
*/
@@ -57,8 +57,18 @@ function runDrizzleMigrations() {
}
}
// Create drizzle instance with the SQLite client
export const db = drizzle({ client: sqlite });
// Run Drizzle migrations after db is initialized
runDrizzleMigrations();
// Export all table definitions from schema
export { users, events, configs, repositories, mirrorJobs, organizations } from "./schema";
export {
users,
events,
configs,
repositories,
mirrorJobs,
organizations,
sessions,
accounts,
verificationTokens
} from "./schema";

View File

@@ -8,6 +8,7 @@ export const userSchema = z.object({
username: z.string(),
password: z.string(),
email: z.string().email(),
emailVerified: z.boolean().default(false),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
});
@@ -215,6 +216,7 @@ export const users = sqliteTable("users", {
username: text("username").notNull(),
password: text("password").notNull(),
email: text("email").notNull(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
@@ -434,6 +436,70 @@ export const organizations = sqliteTable("organizations", {
};
});
// ===== Better Auth Tables =====
// Sessions table
export const sessions = sqliteTable("sessions", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
userId: text("user_id").notNull().references(() => users.id),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
userIdIdx: index("idx_sessions_user_id").on(table.userId),
tokenIdx: index("idx_sessions_token").on(table.token),
expiresAtIdx: index("idx_sessions_expires_at").on(table.expiresAt),
};
});
// Accounts table (for OAuth providers and credentials)
export const accounts = sqliteTable("accounts", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id),
providerId: text("provider_id").notNull(),
providerUserId: text("provider_user_id").notNull(),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
expiresAt: integer("expires_at", { mode: "timestamp" }),
password: text("password"), // For credential provider
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
userIdIdx: index("idx_accounts_user_id").on(table.userId),
providerIdx: index("idx_accounts_provider").on(table.providerId, table.providerUserId),
};
});
// Verification tokens table
export const verificationTokens = sqliteTable("verification_tokens", {
id: text("id").primaryKey(),
token: text("token").notNull().unique(),
identifier: text("identifier").notNull(),
type: text("type").notNull(), // email, password-reset, etc
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}, (table) => {
return {
tokenIdx: index("idx_verification_tokens_token").on(table.token),
identifierIdx: index("idx_verification_tokens_identifier").on(table.identifier),
};
});
// Export type definitions
export type User = z.infer<typeof userSchema>;
export type Config = z.infer<typeof configSchema>;

View File

@@ -0,0 +1,58 @@
import type { APIRoute, APIContext } from "astro";
import { auth } from "@/lib/auth";
/**
* Get authenticated user from request
* @param request - The request object from Astro API route
* @returns The authenticated user or null if not authenticated
*/
export async function getAuthenticatedUser(request: Request) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
return session ? session.user : null;
} catch (error) {
console.error("Error getting session:", error);
return null;
}
}
/**
* Require authentication for API routes
* Returns an error response if user is not authenticated
* @param context - The API context from Astro
* @returns Object with user if authenticated, or error response if not
*/
export async function requireAuth(context: APIContext) {
const user = await getAuthenticatedUser(context.request);
if (!user) {
return {
user: null,
response: new Response(
JSON.stringify({
success: false,
error: "Unauthorized - Please log in",
}),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
),
};
}
return { user, response: null };
}
/**
* Get user ID from authenticated session
* @param request - The request object from Astro API route
* @returns The user ID or null if not authenticated
*/
export async function getAuthenticatedUserId(request: Request): Promise<string | null> {
const user = await getAuthenticatedUser(request);
return user?.id || null;
}

View File

@@ -3,6 +3,7 @@ import { initializeRecovery, hasJobsNeedingRecovery, getRecoveryStatus } from '.
import { startCleanupService, stopCleanupService } from './lib/cleanup-service';
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
import { setupSignalHandlers } from './lib/signal-handlers';
import { auth } from './lib/auth';
// Flag to track if recovery has been initialized
let recoveryInitialized = false;
@@ -11,6 +12,25 @@ let cleanupServiceStarted = false;
let shutdownManagerInitialized = false;
export const onRequest = defineMiddleware(async (context, next) => {
// Handle Better Auth session
try {
const session = await auth.api.getSession({
headers: context.request.headers,
});
if (session) {
context.locals.user = session.user;
context.locals.session = session.session;
} else {
context.locals.user = null;
context.locals.session = null;
}
} catch (error) {
// If there's an error getting the session, set to null
context.locals.user = null;
context.locals.session = null;
}
// Initialize shutdown manager and signal handlers first
if (!shutdownManagerInitialized) {
try {

View File

@@ -0,0 +1,10 @@
import { auth } from "@/lib/auth";
import type { APIRoute } from "astro";
export const ALL: APIRoute = async (ctx) => {
// If you want to use rate limiting, make sure to set the 'x-forwarded-for' header
// to the request headers from the context
// ctx.request.headers.set("x-forwarded-for", ctx.clientAddress);
return auth.handler(ctx.request);
};

View File

@@ -0,0 +1,30 @@
import type { APIRoute } from "astro";
import { db, users } from "@/lib/db";
import { sql } from "drizzle-orm";
export const GET: APIRoute = async () => {
try {
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0].count;
if (userCount === 0) {
return new Response(JSON.stringify({ error: "No users found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ userCount }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};

View File

@@ -0,0 +1,13 @@
# Legacy Auth Routes Backup
These files are the original authentication routes before migrating to Better Auth.
They are kept here as a reference during the migration process.
## Migration Notes
- `index.ts` - Handled user session validation and getting current user
- `login.ts` - Handled user login with email/password
- `logout.ts` - Handled user logout and session cleanup
- `register.ts` - Handled new user registration
All these endpoints are now handled by Better Auth through the catch-all route `[...all].ts`.

View File

@@ -2,36 +2,17 @@ import type { APIRoute } from "astro";
import { db, organizations } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import jwt from "jsonwebtoken";
import { requireAuth } from "@/lib/utils/auth-helpers";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
export const PATCH: APIRoute = async (context) => {
try {
// Get token from Authorization header or cookies
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
// Check authentication
const { user, response } = await requireAuth(context);
if (response) return response;
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = user!.id;
// Verify token and get user ID
let userId: string;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
userId = decoded.id;
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const orgId = params.id;
const orgId = context.params.id;
if (!orgId) {
return new Response(JSON.stringify({ error: "Organization ID is required" }), {
status: 400,
@@ -39,7 +20,7 @@ export const PATCH: APIRoute = async ({ request, params, cookies }) => {
});
}
const body = await request.json();
const body = await context.request.json();
const { destinationOrg } = body;
// Validate that the organization belongs to the user

View File

@@ -2,36 +2,17 @@ import type { APIRoute } from "astro";
import { db, repositories } from "@/lib/db";
import { eq, and } from "drizzle-orm";
import { createSecureErrorResponse } from "@/lib/utils";
import jwt from "jsonwebtoken";
import { requireAuth } from "@/lib/utils/auth-helpers";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
export const PATCH: APIRoute = async ({ request, params, cookies }) => {
export const PATCH: APIRoute = async (context) => {
try {
// Get token from Authorization header or cookies
const authHeader = request.headers.get("Authorization");
const token = authHeader?.split(" ")[1] || cookies.get("token")?.value;
// Check authentication
const { user, response } = await requireAuth(context);
if (response) return response;
if (!token) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const userId = user!.id;
// Verify token and get user ID
let userId: string;
try {
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
userId = decoded.id;
} catch (error) {
return new Response(JSON.stringify({ error: "Invalid token" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const repoId = params.id;
const repoId = context.params.id;
if (!repoId) {
return new Response(JSON.stringify({ error: "Repository ID is required" }), {
status: 400,
@@ -39,7 +20,7 @@ export const PATCH: APIRoute = async ({ request, params, cookies }) => {
});
}
const body = await request.json();
const body = await context.request.json();
const { destinationOrg } = body;
// Validate that the repository belongs to the user

View File

@@ -1,12 +1,13 @@
---
import '../styles/global.css';
import App from '@/components/layout/MainLayout';
import { db, repositories, mirrorJobs, client } from '@/lib/db';
import { db, repositories, mirrorJobs, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
import ThemeScript from '@/components/theme/ThemeScript.astro';
// Check if any users exist in the database
const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`);
const userCount = userCountResult.rows[0].count;
const userCountResult = await db.select({ count: sql<number>`count(*)` }).from(users);
const userCount = userCountResult[0]?.count || 0;
// Redirect to signup if no users exist
if (userCount === 0) {

View File

@@ -2,11 +2,14 @@
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { LoginForm } from '@/components/auth/LoginForm';
import { client } from '../lib/db';
import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
// Check if any users exist in the database
const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`);
const userCount = userCountResult.rows[0].count;
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0].count;
// Redirect to signup if no users exist
if (userCount === 0) {

View File

@@ -2,11 +2,14 @@
import '../styles/global.css';
import ThemeScript from '@/components/theme/ThemeScript.astro';
import { SignupForm } from '@/components/auth/SignupForm';
import { client } from '../lib/db';
import { db, users } from '@/lib/db';
import { sql } from 'drizzle-orm';
// Check if any users exist in the database
const userCountResult = await client.execute(`SELECT COUNT(*) as count FROM users`);
const userCount = userCountResult.rows[0]?.count;
const userCountResult = await db
.select({ count: sql<number>`count(*)` })
.from(users);
const userCount = userCountResult[0]?.count;
// Redirect to login if users already exist
if (userCount !== null && Number(userCount) > 0) {