-
diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx
index 53f52f1..6573733 100644
--- a/src/components/auth/SignupForm.tsx
+++ b/src/components/auth/SignupForm.tsx
@@ -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
) {
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 {
diff --git a/src/hooks/useAuth-legacy.ts b/src/hooks/useAuth-legacy.ts
new file mode 100644
index 0000000..01b9432
--- /dev/null
+++ b/src/hooks/useAuth-legacy.ts
@@ -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;
+ register: (
+ username: string,
+ email: string,
+ password: string
+ ) => Promise;
+ logout: () => Promise;
+ refreshUser: () => Promise; // Added refreshUser function
+}
+
+const AuthContext: Context = createContext<
+ AuthContextType | undefined
+>(undefined);
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+ const [user, setUser] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(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;
+}
diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts
index 01b9432..9a78cec 100644
--- a/src/hooks/useAuth.ts
+++ b/src/hooks/useAuth.ts
@@ -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;
+ login: (email: string, password: string, username?: string) => Promise;
register: (
username: string,
email: string,
password: string
) => Promise;
logout: () => Promise;
- refreshUser: () => Promise; // Added refreshUser function
+ refreshUser: () => Promise;
}
const AuthContext: Context = createContext<
@@ -28,60 +29,53 @@ const AuthContext: Context = createContext<
>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
- const [user, setUser] = useState(null);
- const [isLoading, setIsLoading] = useState(true);
+ const betterAuthSession = useBetterAuthSession();
const [error, setError] = useState(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 };
\ No newline at end of file
diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts
new file mode 100644
index 0000000..ad44b06
--- /dev/null
+++ b/src/lib/auth-client.ts
@@ -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>["data"];
+export type AuthUser = Session extends { user: infer U } ? U : never;
\ No newline at end of file
diff --git a/src/lib/auth-config.ts b/src/lib/auth-config.ts
new file mode 100644
index 0000000..1bea257
--- /dev/null
+++ b/src/lib/auth-config.ts
@@ -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",
+ ],
+ });
+}
\ No newline at end of file
diff --git a/src/lib/auth-oidc-config.example.ts b/src/lib/auth-oidc-config.example.ts
new file mode 100644
index 0000000..ab8cb97
--- /dev/null
+++ b/src/lib/auth-oidc-config.example.ts
@@ -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 = {};
+
+ // 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",
+ },
+});
+*/
\ No newline at end of file
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..b0ef594
--- /dev/null
+++ b/src/lib/auth.ts
@@ -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;
\ No newline at end of file
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index 7b62d25..b59889b 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -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";
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 12ca324..cf84227 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -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;
export type Config = z.infer;
diff --git a/src/lib/utils/auth-helpers.ts b/src/lib/utils/auth-helpers.ts
new file mode 100644
index 0000000..10e2336
--- /dev/null
+++ b/src/lib/utils/auth-helpers.ts
@@ -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 {
+ const user = await getAuthenticatedUser(request);
+ return user?.id || null;
+}
\ No newline at end of file
diff --git a/src/middleware.ts b/src/middleware.ts
index 7fa984c..3f7fc45 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -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 {
diff --git a/src/pages/api/auth/[...all].ts b/src/pages/api/auth/[...all].ts
new file mode 100644
index 0000000..d4077f4
--- /dev/null
+++ b/src/pages/api/auth/[...all].ts
@@ -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);
+};
\ No newline at end of file
diff --git a/src/pages/api/auth/check-users.ts b/src/pages/api/auth/check-users.ts
new file mode 100644
index 0000000..f726cdb
--- /dev/null
+++ b/src/pages/api/auth/check-users.ts
@@ -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`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" },
+ });
+ }
+};
\ No newline at end of file
diff --git a/src/pages/api/auth/legacy-backup/README.md b/src/pages/api/auth/legacy-backup/README.md
new file mode 100644
index 0000000..f0bdf3a
--- /dev/null
+++ b/src/pages/api/auth/legacy-backup/README.md
@@ -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`.
\ No newline at end of file
diff --git a/src/pages/api/auth/index.ts b/src/pages/api/auth/legacy-backup/index.ts
similarity index 100%
rename from src/pages/api/auth/index.ts
rename to src/pages/api/auth/legacy-backup/index.ts
diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/legacy-backup/login.ts
similarity index 100%
rename from src/pages/api/auth/login.ts
rename to src/pages/api/auth/legacy-backup/login.ts
diff --git a/src/pages/api/auth/logout.ts b/src/pages/api/auth/legacy-backup/logout.ts
similarity index 100%
rename from src/pages/api/auth/logout.ts
rename to src/pages/api/auth/legacy-backup/logout.ts
diff --git a/src/pages/api/auth/register.ts b/src/pages/api/auth/legacy-backup/register.ts
similarity index 100%
rename from src/pages/api/auth/register.ts
rename to src/pages/api/auth/legacy-backup/register.ts
diff --git a/src/pages/api/organizations/[id].ts b/src/pages/api/organizations/[id].ts
index 9a3c888..152ccac 100644
--- a/src/pages/api/organizations/[id].ts
+++ b/src/pages/api/organizations/[id].ts
@@ -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
diff --git a/src/pages/api/repositories/[id].ts b/src/pages/api/repositories/[id].ts
index b79bcce..debbc07 100644
--- a/src/pages/api/repositories/[id].ts
+++ b/src/pages/api/repositories/[id].ts
@@ -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
diff --git a/src/pages/index.astro b/src/pages/index.astro
index 854fb9f..dfd1253 100644
--- a/src/pages/index.astro
+++ b/src/pages/index.astro
@@ -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`count(*)` }).from(users);
+const userCount = userCountResult[0]?.count || 0;
// Redirect to signup if no users exist
if (userCount === 0) {
diff --git a/src/pages/login.astro b/src/pages/login.astro
index 3cf82a6..9ac2759 100644
--- a/src/pages/login.astro
+++ b/src/pages/login.astro
@@ -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`count(*)` })
+ .from(users);
+const userCount = userCountResult[0].count;
// Redirect to signup if no users exist
if (userCount === 0) {
diff --git a/src/pages/signup.astro b/src/pages/signup.astro
index d7f09d3..71a96ee 100644
--- a/src/pages/signup.astro
+++ b/src/pages/signup.astro
@@ -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`count(*)` })
+ .from(users);
+const userCount = userCountResult[0]?.count;
// Redirect to login if users already exist
if (userCount !== null && Number(userCount) > 0) {