zephyrfs/zephyrfs-web / 095d099

Browse files

4.2 authentication system with email & OAuth

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
095d099187a428bd3ccbbea9a51bb1f592353f8d
Parents
cd963ec
Tree
20d3390

7 changed files

StatusFile+-
M server/package.json 8 1
A server/src/database/connection.ts 112 0
A server/src/database/schema.sql 131 0
A server/src/database/types.ts 138 0
A server/src/routes/auth-v2.ts 613 0
A server/src/routes/oauth.ts 500 0
A server/src/services/email.ts 371 0
server/package.jsonmodified
@@ -18,15 +18,22 @@
1818
     "@fastify/multipart": "^8.0.0",
1919
     "@fastify/static": "^6.12.0",
2020
     "@fastify/websocket": "^8.3.1",
21
+    "@fastify/oauth2": "^7.8.0",
2122
     "webdav": "^5.3.0",
2223
     "ws": "^8.14.2",
2324
     "zod": "^3.22.4",
24
-    "archiver": "^6.0.1"
25
+    "archiver": "^6.0.1",
26
+    "bcrypt": "^5.1.1",
27
+    "nodemailer": "^6.9.7",
28
+    "sqlite3": "^5.1.6",
29
+    "kysely": "^0.27.2"
2530
   },
2631
   "devDependencies": {
2732
     "@types/node": "^20.8.10",
2833
     "@types/ws": "^8.5.8",
2934
     "@types/archiver": "^6.0.2",
35
+    "@types/bcrypt": "^5.0.2",
36
+    "@types/nodemailer": "^6.4.14",
3037
     "@typescript-eslint/eslint-plugin": "^6.9.1",
3138
     "@typescript-eslint/parser": "^6.9.1",
3239
     "eslint": "^8.52.0",
server/src/database/connection.tsadded
@@ -0,0 +1,112 @@
1
+import { Kysely, SqliteDialect } from 'kysely';
2
+import SQLite from 'sqlite3';
3
+import { readFileSync } from 'fs';
4
+import { join } from 'path';
5
+import type { Database } from './types.js';
6
+
7
+let db: Kysely<Database> | null = null;
8
+
9
+export function initializeDatabase(dbPath?: string): Kysely<Database> {
10
+  if (db) {
11
+    return db;
12
+  }
13
+
14
+  const databasePath = dbPath || process.env.DATABASE_PATH || ':memory:';
15
+
16
+  const dialect = new SqliteDialect({
17
+    database: new SQLite.Database(databasePath),
18
+  });
19
+
20
+  db = new Kysely<Database>({
21
+    dialect,
22
+  });
23
+
24
+  return db;
25
+}
26
+
27
+export async function setupDatabase(): Promise<void> {
28
+  const database = getDatabase();
29
+
30
+  // Read and execute schema
31
+  const schemaPath = join(new URL(import.meta.url).pathname, '../schema.sql');
32
+  const schema = readFileSync(schemaPath, 'utf-8');
33
+
34
+  // Split and execute each statement
35
+  const statements = schema
36
+    .split(';')
37
+    .map(stmt => stmt.trim())
38
+    .filter(stmt => stmt.length > 0);
39
+
40
+  for (const statement of statements) {
41
+    try {
42
+      await database.executeQuery({
43
+        sql: statement,
44
+        parameters: [],
45
+      });
46
+    } catch (error) {
47
+      // Ignore table already exists errors
48
+      if (!error.message?.includes('already exists')) {
49
+        console.error('Database setup error:', error);
50
+        throw error;
51
+      }
52
+    }
53
+  }
54
+}
55
+
56
+export function getDatabase(): Kysely<Database> {
57
+  if (!db) {
58
+    throw new Error('Database not initialized. Call initializeDatabase() first.');
59
+  }
60
+  return db;
61
+}
62
+
63
+export async function closeDatabase(): Promise<void> {
64
+  if (db) {
65
+    await db.destroy();
66
+    db = null;
67
+  }
68
+}
69
+
70
+// Helper function to convert snake_case to camelCase for database rows
71
+export function toCamelCase<T>(obj: any): T {
72
+  if (obj === null || obj === undefined) {
73
+    return obj;
74
+  }
75
+
76
+  if (Array.isArray(obj)) {
77
+    return obj.map(toCamelCase) as T;
78
+  }
79
+
80
+  if (typeof obj === 'object' && obj.constructor === Object) {
81
+    const result: any = {};
82
+    for (const [key, value] of Object.entries(obj)) {
83
+      const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
84
+      result[camelKey] = toCamelCase(value);
85
+    }
86
+    return result;
87
+  }
88
+
89
+  return obj;
90
+}
91
+
92
+// Helper function to convert camelCase to snake_case for database queries
93
+export function toSnakeCase<T>(obj: any): T {
94
+  if (obj === null || obj === undefined) {
95
+    return obj;
96
+  }
97
+
98
+  if (Array.isArray(obj)) {
99
+    return obj.map(toSnakeCase) as T;
100
+  }
101
+
102
+  if (typeof obj === 'object' && obj.constructor === Object) {
103
+    const result: any = {};
104
+    for (const [key, value] of Object.entries(obj)) {
105
+      const snakeKey = key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`);
106
+      result[snakeKey] = toSnakeCase(value);
107
+    }
108
+    return result;
109
+  }
110
+
111
+  return obj;
112
+}
server/src/database/schema.sqladded
@@ -0,0 +1,131 @@
1
+-- ZephyrFS Authentication Database Schema
2
+
3
+-- Users table with email registration and OAuth support
4
+CREATE TABLE users (
5
+    id TEXT PRIMARY KEY,
6
+    email TEXT UNIQUE NOT NULL,
7
+    username TEXT UNIQUE,
8
+    password_hash TEXT,
9
+    user_type TEXT CHECK (user_type IN ('backup', 'volunteer', 'admin')) NOT NULL DEFAULT 'backup',
10
+    email_verified BOOLEAN DEFAULT FALSE,
11
+    email_verification_token TEXT,
12
+    email_verification_expires_at DATETIME,
13
+    password_reset_token TEXT,
14
+    password_reset_expires_at DATETIME,
15
+    github_id TEXT UNIQUE,
16
+    github_username TEXT,
17
+    github_avatar_url TEXT,
18
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
19
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
20
+    last_login_at DATETIME,
21
+    profile_data TEXT -- JSON storage for additional profile info
22
+);
23
+
24
+-- OAuth accounts table for multiple providers
25
+CREATE TABLE oauth_accounts (
26
+    id TEXT PRIMARY KEY,
27
+    user_id TEXT NOT NULL,
28
+    provider TEXT NOT NULL,
29
+    provider_account_id TEXT NOT NULL,
30
+    access_token TEXT,
31
+    refresh_token TEXT,
32
+    expires_at DATETIME,
33
+    token_type TEXT,
34
+    scope TEXT,
35
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
36
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
37
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
38
+    UNIQUE(provider, provider_account_id)
39
+);
40
+
41
+-- User sessions table
42
+CREATE TABLE user_sessions (
43
+    id TEXT PRIMARY KEY,
44
+    user_id TEXT NOT NULL,
45
+    session_token TEXT UNIQUE NOT NULL,
46
+    expires_at DATETIME NOT NULL,
47
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
48
+    last_access_at DATETIME DEFAULT CURRENT_TIMESTAMP,
49
+    ip_address TEXT,
50
+    user_agent TEXT,
51
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
52
+);
53
+
54
+-- Email verification attempts tracking
55
+CREATE TABLE email_verifications (
56
+    id TEXT PRIMARY KEY,
57
+    user_id TEXT NOT NULL,
58
+    email TEXT NOT NULL,
59
+    token TEXT NOT NULL,
60
+    attempts INTEGER DEFAULT 0,
61
+    verified_at DATETIME,
62
+    expires_at DATETIME NOT NULL,
63
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
64
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
65
+);
66
+
67
+-- Password reset attempts tracking
68
+CREATE TABLE password_resets (
69
+    id TEXT PRIMARY KEY,
70
+    user_id TEXT NOT NULL,
71
+    token TEXT NOT NULL,
72
+    attempts INTEGER DEFAULT 0,
73
+    used_at DATETIME,
74
+    expires_at DATETIME NOT NULL,
75
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
76
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
77
+);
78
+
79
+-- User onboarding progress tracking
80
+CREATE TABLE user_onboarding (
81
+    id TEXT PRIMARY KEY,
82
+    user_id TEXT NOT NULL,
83
+    step TEXT NOT NULL,
84
+    completed BOOLEAN DEFAULT FALSE,
85
+    data TEXT, -- JSON storage for step-specific data
86
+    completed_at DATETIME,
87
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
88
+    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
89
+);
90
+
91
+-- Indexes for performance
92
+CREATE INDEX idx_users_email ON users (email);
93
+CREATE INDEX idx_users_username ON users (username);
94
+CREATE INDEX idx_users_github_id ON oauth_accounts (provider_account_id);
95
+CREATE INDEX idx_sessions_token ON user_sessions (session_token);
96
+CREATE INDEX idx_sessions_user_id ON user_sessions (user_id);
97
+CREATE INDEX idx_email_verifications_token ON email_verifications (token);
98
+CREATE INDEX idx_password_resets_token ON password_resets (token);
99
+
100
+-- Insert default admin user
101
+INSERT INTO users (
102
+    id,
103
+    email,
104
+    username,
105
+    password_hash,
106
+    user_type,
107
+    email_verified
108
+) VALUES (
109
+    'admin',
110
+    'admin@zephyrfs.org',
111
+    'admin',
112
+    '$2b$10$rKGHZ0aB0qKhP0DqW8qZHu2wF2nF.FqD3tYw1pV6gB2wZ8vY0gG4u', -- 'admin' hashed
113
+    'admin',
114
+    TRUE
115
+);
116
+
117
+INSERT INTO users (
118
+    id,
119
+    email,
120
+    username,
121
+    password_hash,
122
+    user_type,
123
+    email_verified
124
+) VALUES (
125
+    'demo',
126
+    'demo@zephyrfs.org',
127
+    'demo',
128
+    '$2b$10$rKGHZ0aB0qKhP0DqW8qZHu2wF2nF.FqD3tYw1pV6gB2wZ8vY0gG4u', -- 'demo' hashed
129
+    'backup',
130
+    TRUE
131
+);
server/src/database/types.tsadded
@@ -0,0 +1,138 @@
1
+// Database types for ZephyrFS authentication system
2
+
3
+export interface User {
4
+  id: string;
5
+  email: string;
6
+  username?: string;
7
+  passwordHash?: string;
8
+  userType: 'backup' | 'volunteer' | 'admin';
9
+  emailVerified: boolean;
10
+  emailVerificationToken?: string;
11
+  emailVerificationExpiresAt?: Date;
12
+  passwordResetToken?: string;
13
+  passwordResetExpiresAt?: Date;
14
+  githubId?: string;
15
+  githubUsername?: string;
16
+  githubAvatarUrl?: string;
17
+  createdAt: Date;
18
+  updatedAt: Date;
19
+  lastLoginAt?: Date;
20
+  profileData?: string; // JSON storage
21
+}
22
+
23
+export interface OAuthAccount {
24
+  id: string;
25
+  userId: string;
26
+  provider: string;
27
+  providerAccountId: string;
28
+  accessToken?: string;
29
+  refreshToken?: string;
30
+  expiresAt?: Date;
31
+  tokenType?: string;
32
+  scope?: string;
33
+  createdAt: Date;
34
+  updatedAt: Date;
35
+}
36
+
37
+export interface UserSession {
38
+  id: string;
39
+  userId: string;
40
+  sessionToken: string;
41
+  expiresAt: Date;
42
+  createdAt: Date;
43
+  lastAccessAt: Date;
44
+  ipAddress?: string;
45
+  userAgent?: string;
46
+}
47
+
48
+export interface EmailVerification {
49
+  id: string;
50
+  userId: string;
51
+  email: string;
52
+  token: string;
53
+  attempts: number;
54
+  verifiedAt?: Date;
55
+  expiresAt: Date;
56
+  createdAt: Date;
57
+}
58
+
59
+export interface PasswordReset {
60
+  id: string;
61
+  userId: string;
62
+  token: string;
63
+  attempts: number;
64
+  usedAt?: Date;
65
+  expiresAt: Date;
66
+  createdAt: Date;
67
+}
68
+
69
+export interface UserOnboarding {
70
+  id: string;
71
+  userId: string;
72
+  step: string;
73
+  completed: boolean;
74
+  data?: string; // JSON storage
75
+  completedAt?: Date;
76
+  createdAt: Date;
77
+}
78
+
79
+export interface Database {
80
+  users: User;
81
+  oauth_accounts: OAuthAccount;
82
+  user_sessions: UserSession;
83
+  email_verifications: EmailVerification;
84
+  password_resets: PasswordReset;
85
+  user_onboarding: UserOnboarding;
86
+}
87
+
88
+// API types for requests/responses
89
+export interface RegisterRequest {
90
+  email: string;
91
+  username?: string;
92
+  password: string;
93
+  userType: 'backup' | 'volunteer';
94
+}
95
+
96
+export interface LoginRequest {
97
+  email?: string;
98
+  username?: string;
99
+  password?: string;
100
+  token?: string;
101
+}
102
+
103
+export interface AuthResponse {
104
+  token: string;
105
+  refreshToken: string;
106
+  expiresIn: number;
107
+  user: PublicUser;
108
+}
109
+
110
+export interface PublicUser {
111
+  id: string;
112
+  email: string;
113
+  username?: string;
114
+  userType: 'backup' | 'volunteer' | 'admin';
115
+  emailVerified: boolean;
116
+  githubUsername?: string;
117
+  githubAvatarUrl?: string;
118
+  createdAt: Date;
119
+}
120
+
121
+export interface EmailVerificationRequest {
122
+  token: string;
123
+}
124
+
125
+export interface PasswordResetRequest {
126
+  email: string;
127
+}
128
+
129
+export interface PasswordResetConfirmRequest {
130
+  token: string;
131
+  newPassword: string;
132
+}
133
+
134
+export interface OnboardingUpdateRequest {
135
+  step: string;
136
+  data?: Record<string, any>;
137
+  completed?: boolean;
138
+}
server/src/routes/auth-v2.tsadded
@@ -0,0 +1,613 @@
1
+import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+import bcrypt from 'bcrypt';
3
+import { z } from 'zod';
4
+import { randomBytes, randomUUID } from 'crypto';
5
+import { getDatabase, toCamelCase } from '../database/connection.js';
6
+import { EmailService } from '../services/email.js';
7
+import type {
8
+  RegisterRequest,
9
+  LoginRequest,
10
+  AuthResponse,
11
+  PublicUser,
12
+  EmailVerificationRequest,
13
+  PasswordResetRequest,
14
+  PasswordResetConfirmRequest,
15
+  OnboardingUpdateRequest,
16
+} from '../database/types.js';
17
+
18
+// Validation schemas
19
+const registerSchema = z.object({
20
+  email: z.string().email('Invalid email address'),
21
+  username: z.string().min(3).max(30).optional(),
22
+  password: z.string().min(8, 'Password must be at least 8 characters'),
23
+  userType: z.enum(['backup', 'volunteer'], {
24
+    required_error: 'Please select whether you want to backup files or volunteer storage',
25
+  }),
26
+});
27
+
28
+const loginSchema = z.object({
29
+  email: z.string().email().optional(),
30
+  username: z.string().optional(),
31
+  password: z.string().optional(),
32
+  token: z.string().optional(),
33
+}).refine(
34
+  (data) => (data.email || data.username) && data.password || data.token,
35
+  'Email/username and password, or token required'
36
+);
37
+
38
+const emailVerificationSchema = z.object({
39
+  token: z.string().min(1, 'Verification token required'),
40
+});
41
+
42
+const passwordResetSchema = z.object({
43
+  email: z.string().email('Invalid email address'),
44
+});
45
+
46
+const passwordResetConfirmSchema = z.object({
47
+  token: z.string().min(1, 'Reset token required'),
48
+  newPassword: z.string().min(8, 'Password must be at least 8 characters'),
49
+});
50
+
51
+const onboardingUpdateSchema = z.object({
52
+  step: z.string().min(1),
53
+  data: z.record(z.any()).optional(),
54
+  completed: z.boolean().optional(),
55
+});
56
+
57
+let emailService: EmailService;
58
+
59
+export async function authV2Routes(fastify: FastifyInstance) {
60
+  const db = getDatabase();
61
+
62
+  // Initialize email service
63
+  emailService = new EmailService({
64
+    host: process.env.SMTP_HOST || 'smtp.ethereal.email',
65
+    port: parseInt(process.env.SMTP_PORT || '587'),
66
+    secure: process.env.SMTP_SECURE === 'true',
67
+    auth: process.env.SMTP_USER && process.env.SMTP_PASS ? {
68
+      user: process.env.SMTP_USER,
69
+      pass: process.env.SMTP_PASS,
70
+    } : undefined,
71
+    from: process.env.SMTP_FROM || 'ZephyrFS <noreply@zephyrfs.org>',
72
+  });
73
+
74
+  // Helper function to create public user object
75
+  const toPublicUser = (user: any): PublicUser => ({
76
+    id: user.id,
77
+    email: user.email,
78
+    username: user.username,
79
+    userType: user.userType || user.user_type,
80
+    emailVerified: user.emailVerified || user.email_verified,
81
+    githubUsername: user.githubUsername || user.github_username,
82
+    githubAvatarUrl: user.githubAvatarUrl || user.github_avatar_url,
83
+    createdAt: new Date(user.createdAt || user.created_at),
84
+  });
85
+
86
+  // User registration
87
+  fastify.post<{
88
+    Body: RegisterRequest;
89
+  }>('/auth/register', {
90
+    schema: { body: registerSchema },
91
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
92
+    const { email, username, password, userType } = request.body as RegisterRequest;
93
+
94
+    try {
95
+      // Check if user already exists
96
+      const existingUser = await db
97
+        .selectFrom('users')
98
+        .selectAll()
99
+        .where((eb) => eb.or([
100
+          eb('email', '=', email),
101
+          ...(username ? [eb('username', '=', username)] : [])
102
+        ]))
103
+        .executeTakeFirst();
104
+
105
+      if (existingUser) {
106
+        if (existingUser.email === email) {
107
+          throw fastify.httpErrors.conflict('Email already registered');
108
+        }
109
+        if (existingUser.username === username) {
110
+          throw fastify.httpErrors.conflict('Username already taken');
111
+        }
112
+      }
113
+
114
+      // Hash password
115
+      const passwordHash = await bcrypt.hash(password, 12);
116
+
117
+      // Generate verification token
118
+      const verificationToken = randomBytes(32).toString('hex');
119
+      const verificationExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
120
+
121
+      // Create user
122
+      const userId = randomUUID();
123
+      await db
124
+        .insertInto('users')
125
+        .values({
126
+          id: userId,
127
+          email,
128
+          username,
129
+          password_hash: passwordHash,
130
+          user_type: userType,
131
+          email_verified: false,
132
+          email_verification_token: verificationToken,
133
+          email_verification_expires_at: verificationExpiresAt.toISOString(),
134
+          created_at: new Date().toISOString(),
135
+          updated_at: new Date().toISOString(),
136
+        })
137
+        .execute();
138
+
139
+      // Create email verification record
140
+      await db
141
+        .insertInto('email_verifications')
142
+        .values({
143
+          id: randomUUID(),
144
+          user_id: userId,
145
+          email,
146
+          token: verificationToken,
147
+          attempts: 0,
148
+          expires_at: verificationExpiresAt.toISOString(),
149
+          created_at: new Date().toISOString(),
150
+        })
151
+        .execute();
152
+
153
+      // Send verification email
154
+      try {
155
+        await emailService.sendEmailVerification(email, verificationToken, username);
156
+      } catch (emailError) {
157
+        fastify.log.error(emailError, 'Failed to send verification email');
158
+        // Don't fail registration if email fails
159
+      }
160
+
161
+      // Initialize onboarding steps
162
+      const onboardingSteps = userType === 'volunteer'
163
+        ? ['user-type-selection', 'storage-setup', 'desktop-app', 'node-configuration']
164
+        : ['user-type-selection', 'backup-setup', 'first-upload'];
165
+
166
+      for (const step of onboardingSteps) {
167
+        await db
168
+          .insertInto('user_onboarding')
169
+          .values({
170
+            id: randomUUID(),
171
+            user_id: userId,
172
+            step,
173
+            completed: step === 'user-type-selection', // First step completed
174
+            data: step === 'user-type-selection' ? JSON.stringify({ userType }) : null,
175
+            completed_at: step === 'user-type-selection' ? new Date().toISOString() : null,
176
+            created_at: new Date().toISOString(),
177
+          })
178
+          .execute();
179
+      }
180
+
181
+      return {
182
+        success: true,
183
+        message: 'Registration successful! Please check your email to verify your account.',
184
+        userId,
185
+      };
186
+    } catch (error) {
187
+      if (error.statusCode) {
188
+        throw error;
189
+      }
190
+      fastify.log.error(error, 'Registration failed');
191
+      throw fastify.httpErrors.internalServerError('Registration failed');
192
+    }
193
+  });
194
+
195
+  // Email verification
196
+  fastify.post<{
197
+    Body: EmailVerificationRequest;
198
+  }>('/auth/verify-email', {
199
+    schema: { body: emailVerificationSchema },
200
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
201
+    const { token } = request.body as EmailVerificationRequest;
202
+
203
+    try {
204
+      // Find verification record
205
+      const verification = await db
206
+        .selectFrom('email_verifications')
207
+        .selectAll()
208
+        .where('token', '=', token)
209
+        .where('verified_at', 'is', null)
210
+        .executeTakeFirst();
211
+
212
+      if (!verification) {
213
+        throw fastify.httpErrors.badRequest('Invalid or expired verification token');
214
+      }
215
+
216
+      if (new Date() > new Date(verification.expires_at)) {
217
+        throw fastify.httpErrors.badRequest('Verification token has expired');
218
+      }
219
+
220
+      // Update verification record
221
+      await db
222
+        .updateTable('email_verifications')
223
+        .set({
224
+          verified_at: new Date().toISOString(),
225
+          attempts: verification.attempts + 1,
226
+        })
227
+        .where('id', '=', verification.id)
228
+        .execute();
229
+
230
+      // Update user
231
+      const user = await db
232
+        .updateTable('users')
233
+        .set({
234
+          email_verified: true,
235
+          email_verification_token: null,
236
+          email_verification_expires_at: null,
237
+          updated_at: new Date().toISOString(),
238
+        })
239
+        .where('id', '=', verification.user_id)
240
+        .returningAll()
241
+        .executeTakeFirstOrThrow();
242
+
243
+      // Send welcome email
244
+      try {
245
+        await emailService.sendWelcomeEmail(
246
+          user.email,
247
+          user.user_type as 'backup' | 'volunteer',
248
+          user.username || undefined
249
+        );
250
+      } catch (emailError) {
251
+        fastify.log.error(emailError, 'Failed to send welcome email');
252
+      }
253
+
254
+      return {
255
+        success: true,
256
+        message: 'Email verified successfully! Welcome to ZephyrFS!',
257
+        user: toPublicUser(user),
258
+      };
259
+    } catch (error) {
260
+      if (error.statusCode) {
261
+        throw error;
262
+      }
263
+      fastify.log.error(error, 'Email verification failed');
264
+      throw fastify.httpErrors.internalServerError('Email verification failed');
265
+    }
266
+  });
267
+
268
+  // Enhanced login
269
+  fastify.post<{
270
+    Body: LoginRequest;
271
+  }>('/auth/login', {
272
+    schema: { body: loginSchema },
273
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
274
+    const { email, username, password, token } = request.body as LoginRequest;
275
+
276
+    try {
277
+      let user: any;
278
+
279
+      if (token) {
280
+        // Token-based authentication
281
+        try {
282
+          const decoded = fastify.jwt.verify(token) as { userId: string; username: string };
283
+          user = await db
284
+            .selectFrom('users')
285
+            .selectAll()
286
+            .where('id', '=', decoded.userId)
287
+            .executeTakeFirst();
288
+
289
+          if (!user) {
290
+            throw new Error('User not found');
291
+          }
292
+        } catch (error) {
293
+          throw fastify.httpErrors.unauthorized('Invalid token');
294
+        }
295
+      } else if ((email || username) && password) {
296
+        // Password-based authentication
297
+        user = await db
298
+          .selectFrom('users')
299
+          .selectAll()
300
+          .where((eb) => eb.or([
301
+            ...(email ? [eb('email', '=', email)] : []),
302
+            ...(username ? [eb('username', '=', username)] : [])
303
+          ]))
304
+          .executeTakeFirst();
305
+
306
+        if (!user || !user.password_hash) {
307
+          throw fastify.httpErrors.unauthorized('Invalid credentials');
308
+        }
309
+
310
+        const validPassword = await bcrypt.compare(password, user.password_hash);
311
+        if (!validPassword) {
312
+          throw fastify.httpErrors.unauthorized('Invalid credentials');
313
+        }
314
+
315
+        // Check if email is verified for new registrations
316
+        if (!user.email_verified) {
317
+          throw fastify.httpErrors.forbidden('Please verify your email address before logging in');
318
+        }
319
+      } else {
320
+        throw fastify.httpErrors.badRequest('Email/username and password, or token required');
321
+      }
322
+
323
+      // Create session
324
+      const sessionId = randomUUID();
325
+      const sessionToken = randomBytes(32).toString('hex');
326
+      const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
327
+
328
+      await db
329
+        .insertInto('user_sessions')
330
+        .values({
331
+          id: sessionId,
332
+          user_id: user.id,
333
+          session_token: sessionToken,
334
+          expires_at: expiresAt.toISOString(),
335
+          created_at: new Date().toISOString(),
336
+          last_access_at: new Date().toISOString(),
337
+          ip_address: request.ip,
338
+          user_agent: request.headers['user-agent'],
339
+        })
340
+        .execute();
341
+
342
+      // Update last login
343
+      await db
344
+        .updateTable('users')
345
+        .set({
346
+          last_login_at: new Date().toISOString(),
347
+          updated_at: new Date().toISOString(),
348
+        })
349
+        .where('id', '=', user.id)
350
+        .execute();
351
+
352
+      // Generate tokens
353
+      const accessToken = fastify.jwt.sign(
354
+        { userId: user.id, username: user.username, sessionId },
355
+        { expiresIn: fastify.config.jwtExpiresIn }
356
+      );
357
+
358
+      const refreshToken = fastify.jwt.sign(
359
+        { userId: user.id, sessionId, type: 'refresh' },
360
+        { expiresIn: fastify.config.jwtRefreshExpiresIn }
361
+      );
362
+
363
+      const response: AuthResponse = {
364
+        token: accessToken,
365
+        refreshToken,
366
+        expiresIn: 24 * 60 * 60, // 24 hours in seconds
367
+        user: toPublicUser(user),
368
+      };
369
+
370
+      return response;
371
+    } catch (error) {
372
+      if (error.statusCode) {
373
+        throw error;
374
+      }
375
+      fastify.log.error(error, 'Login failed');
376
+      throw fastify.httpErrors.internalServerError('Login failed');
377
+    }
378
+  });
379
+
380
+  // Password reset request
381
+  fastify.post<{
382
+    Body: PasswordResetRequest;
383
+  }>('/auth/password-reset', {
384
+    schema: { body: passwordResetSchema },
385
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
386
+    const { email } = request.body as PasswordResetRequest;
387
+
388
+    try {
389
+      const user = await db
390
+        .selectFrom('users')
391
+        .selectAll()
392
+        .where('email', '=', email)
393
+        .executeTakeFirst();
394
+
395
+      // Always return success to prevent email enumeration
396
+      if (!user) {
397
+        return {
398
+          success: true,
399
+          message: 'If an account with that email exists, we\'ve sent a password reset link.',
400
+        };
401
+      }
402
+
403
+      // Generate reset token
404
+      const resetToken = randomBytes(32).toString('hex');
405
+      const resetExpiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
406
+
407
+      // Update user with reset token
408
+      await db
409
+        .updateTable('users')
410
+        .set({
411
+          password_reset_token: resetToken,
412
+          password_reset_expires_at: resetExpiresAt.toISOString(),
413
+          updated_at: new Date().toISOString(),
414
+        })
415
+        .where('id', '=', user.id)
416
+        .execute();
417
+
418
+      // Create password reset record
419
+      await db
420
+        .insertInto('password_resets')
421
+        .values({
422
+          id: randomUUID(),
423
+          user_id: user.id,
424
+          token: resetToken,
425
+          attempts: 0,
426
+          expires_at: resetExpiresAt.toISOString(),
427
+          created_at: new Date().toISOString(),
428
+        })
429
+        .execute();
430
+
431
+      // Send reset email
432
+      try {
433
+        await emailService.sendPasswordReset(email, resetToken, user.username || undefined);
434
+      } catch (emailError) {
435
+        fastify.log.error(emailError, 'Failed to send password reset email');
436
+      }
437
+
438
+      return {
439
+        success: true,
440
+        message: 'If an account with that email exists, we\'ve sent a password reset link.',
441
+      };
442
+    } catch (error) {
443
+      fastify.log.error(error, 'Password reset request failed');
444
+      throw fastify.httpErrors.internalServerError('Password reset request failed');
445
+    }
446
+  });
447
+
448
+  // Password reset confirmation
449
+  fastify.post<{
450
+    Body: PasswordResetConfirmRequest;
451
+  }>('/auth/password-reset/confirm', {
452
+    schema: { body: passwordResetConfirmSchema },
453
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
454
+    const { token, newPassword } = request.body as PasswordResetConfirmRequest;
455
+
456
+    try {
457
+      // Find reset record
458
+      const reset = await db
459
+        .selectFrom('password_resets')
460
+        .selectAll()
461
+        .where('token', '=', token)
462
+        .where('used_at', 'is', null)
463
+        .executeTakeFirst();
464
+
465
+      if (!reset) {
466
+        throw fastify.httpErrors.badRequest('Invalid or expired reset token');
467
+      }
468
+
469
+      if (new Date() > new Date(reset.expires_at)) {
470
+        throw fastify.httpErrors.badRequest('Reset token has expired');
471
+      }
472
+
473
+      // Hash new password
474
+      const passwordHash = await bcrypt.hash(newPassword, 12);
475
+
476
+      // Update user
477
+      await db
478
+        .updateTable('users')
479
+        .set({
480
+          password_hash: passwordHash,
481
+          password_reset_token: null,
482
+          password_reset_expires_at: null,
483
+          updated_at: new Date().toISOString(),
484
+        })
485
+        .where('id', '=', reset.user_id)
486
+        .execute();
487
+
488
+      // Mark reset as used
489
+      await db
490
+        .updateTable('password_resets')
491
+        .set({
492
+          used_at: new Date().toISOString(),
493
+          attempts: reset.attempts + 1,
494
+        })
495
+        .where('id', '=', reset.id)
496
+        .execute();
497
+
498
+      // Invalidate all sessions for this user
499
+      await db
500
+        .deleteFrom('user_sessions')
501
+        .where('user_id', '=', reset.user_id)
502
+        .execute();
503
+
504
+      return {
505
+        success: true,
506
+        message: 'Password updated successfully! Please log in with your new password.',
507
+      };
508
+    } catch (error) {
509
+      if (error.statusCode) {
510
+        throw error;
511
+      }
512
+      fastify.log.error(error, 'Password reset confirmation failed');
513
+      throw fastify.httpErrors.internalServerError('Password reset confirmation failed');
514
+    }
515
+  });
516
+
517
+  // Get user onboarding status
518
+  fastify.get('/auth/onboarding', {
519
+    preHandler: fastify.authenticate,
520
+  }, async (request: FastifyRequest) => {
521
+    const user = request.user as { userId: string };
522
+
523
+    const onboardingSteps = await db
524
+      .selectFrom('user_onboarding')
525
+      .selectAll()
526
+      .where('user_id', '=', user.userId)
527
+      .orderBy('created_at', 'asc')
528
+      .execute();
529
+
530
+    return {
531
+      steps: onboardingSteps.map(toCamelCase),
532
+      completed: onboardingSteps.filter(step => step.completed).length,
533
+      total: onboardingSteps.length,
534
+    };
535
+  });
536
+
537
+  // Update onboarding progress
538
+  fastify.post<{
539
+    Body: OnboardingUpdateRequest;
540
+  }>('/auth/onboarding/update', {
541
+    preHandler: fastify.authenticate,
542
+    schema: { body: onboardingUpdateSchema },
543
+  }, async (request: FastifyRequest) => {
544
+    const user = request.user as { userId: string };
545
+    const { step, data, completed } = request.body as OnboardingUpdateRequest;
546
+
547
+    await db
548
+      .updateTable('user_onboarding')
549
+      .set({
550
+        completed: completed || false,
551
+        data: data ? JSON.stringify(data) : null,
552
+        completed_at: completed ? new Date().toISOString() : null,
553
+      })
554
+      .where('user_id', '=', user.userId)
555
+      .where('step', '=', step)
556
+      .execute();
557
+
558
+    return { success: true };
559
+  });
560
+
561
+  // Get current user info (enhanced)
562
+  fastify.get('/auth/me', {
563
+    preHandler: fastify.authenticate,
564
+  }, async (request: FastifyRequest) => {
565
+    const authUser = request.user as { userId: string; sessionId: string };
566
+
567
+    // Get user details
568
+    const user = await db
569
+      .selectFrom('users')
570
+      .selectAll()
571
+      .where('id', '=', authUser.userId)
572
+      .executeTakeFirstOrThrow();
573
+
574
+    // Update session last access
575
+    await db
576
+      .updateTable('user_sessions')
577
+      .set({ last_access_at: new Date().toISOString() })
578
+      .where('id', '=', authUser.sessionId)
579
+      .execute();
580
+
581
+    return toPublicUser(user);
582
+  });
583
+
584
+  // Enhanced logout (cleanup sessions)
585
+  fastify.post('/auth/logout', {
586
+    preHandler: fastify.authenticate,
587
+  }, async (request: FastifyRequest) => {
588
+    const user = request.user as { sessionId: string };
589
+
590
+    // Remove session
591
+    await db
592
+      .deleteFrom('user_sessions')
593
+      .where('id', '=', user.sessionId)
594
+      .execute();
595
+
596
+    return { success: true };
597
+  });
598
+
599
+  // Logout from all devices
600
+  fastify.post('/auth/logout-all', {
601
+    preHandler: fastify.authenticate,
602
+  }, async (request: FastifyRequest) => {
603
+    const user = request.user as { userId: string };
604
+
605
+    // Remove all sessions for user
606
+    await db
607
+      .deleteFrom('user_sessions')
608
+      .where('user_id', '=', user.userId)
609
+      .execute();
610
+
611
+    return { success: true };
612
+  });
613
+}
server/src/routes/oauth.tsadded
@@ -0,0 +1,500 @@
1
+import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+import { randomUUID } from 'crypto';
3
+import { z } from 'zod';
4
+import { getDatabase } from '../database/connection.js';
5
+import { EmailService } from '../services/email.js';
6
+import type { AuthResponse, PublicUser } from '../database/types.js';
7
+
8
+// OAuth callback schemas
9
+const githubCallbackSchema = z.object({
10
+  code: z.string().min(1, 'Authorization code required'),
11
+  state: z.string().optional(),
12
+});
13
+
14
+const userTypeSelectionSchema = z.object({
15
+  userType: z.enum(['backup', 'volunteer']),
16
+  tempUserId: z.string().uuid(),
17
+});
18
+
19
+interface GitHubUser {
20
+  id: number;
21
+  login: string;
22
+  name?: string;
23
+  email?: string;
24
+  avatar_url?: string;
25
+}
26
+
27
+interface GitHubEmail {
28
+  email: string;
29
+  primary: boolean;
30
+  verified: boolean;
31
+}
32
+
33
+let emailService: EmailService;
34
+
35
+export async function oauthRoutes(fastify: FastifyInstance) {
36
+  const db = getDatabase();
37
+
38
+  // Initialize email service
39
+  emailService = new EmailService({
40
+    host: process.env.SMTP_HOST || 'smtp.ethereal.email',
41
+    port: parseInt(process.env.SMTP_PORT || '587'),
42
+    secure: process.env.SMTP_SECURE === 'true',
43
+    auth: process.env.SMTP_USER && process.env.SMTP_PASS ? {
44
+      user: process.env.SMTP_USER,
45
+      pass: process.env.SMTP_PASS,
46
+    } : undefined,
47
+    from: process.env.SMTP_FROM || 'ZephyrFS <noreply@zephyrfs.org>',
48
+  });
49
+
50
+  // Register GitHub OAuth plugin
51
+  await fastify.register(import('@fastify/oauth2'), {
52
+    name: 'githubOAuth2',
53
+    credentials: {
54
+      client: {
55
+        id: process.env.GITHUB_CLIENT_ID!,
56
+        secret: process.env.GITHUB_CLIENT_SECRET!,
57
+      },
58
+      auth: fastify.oauth2.GITHUB_CONFIGURATION,
59
+    },
60
+    scope: ['user:email', 'read:user'],
61
+    startRedirectPath: '/oauth/github/login',
62
+    callbackUri: `${process.env.BACKEND_URL || 'http://localhost:8080'}/oauth/github/callback`,
63
+  });
64
+
65
+  // Helper function to create public user object
66
+  const toPublicUser = (user: any): PublicUser => ({
67
+    id: user.id,
68
+    email: user.email,
69
+    username: user.username,
70
+    userType: user.userType || user.user_type,
71
+    emailVerified: user.emailVerified || user.email_verified,
72
+    githubUsername: user.githubUsername || user.github_username,
73
+    githubAvatarUrl: user.githubAvatarUrl || user.github_avatar_url,
74
+    createdAt: new Date(user.createdAt || user.created_at),
75
+  });
76
+
77
+  // GitHub OAuth login initiation
78
+  fastify.get('/oauth/github/login', async (request: FastifyRequest, reply: FastifyReply) => {
79
+    const state = randomUUID();
80
+
81
+    // Store state for verification (in production, use Redis or database)
82
+    // For now, we'll include it in the OAuth flow and verify in callback
83
+
84
+    return fastify.githubOAuth2.generateAuthorizationUri(request, reply, {
85
+      state,
86
+    });
87
+  });
88
+
89
+  // GitHub OAuth callback
90
+  fastify.get<{
91
+    Querystring: z.infer<typeof githubCallbackSchema>;
92
+  }>('/oauth/github/callback', async (request: FastifyRequest, reply: FastifyReply) => {
93
+    const { code, state } = request.query as z.infer<typeof githubCallbackSchema>;
94
+
95
+    try {
96
+      // Exchange code for token
97
+      const tokenResponse = await fastify.githubOAuth2.getAccessTokenFromAuthorizationCodeFlow(request);
98
+
99
+      if (!tokenResponse.access_token) {
100
+        throw new Error('No access token received');
101
+      }
102
+
103
+      // Fetch GitHub user data
104
+      const userResponse = await fetch('https://api.github.com/user', {
105
+        headers: {
106
+          'Authorization': `Bearer ${tokenResponse.access_token}`,
107
+          'User-Agent': 'ZephyrFS/1.0',
108
+          'Accept': 'application/vnd.github.v3+json',
109
+        },
110
+      });
111
+
112
+      if (!userResponse.ok) {
113
+        throw new Error('Failed to fetch GitHub user data');
114
+      }
115
+
116
+      const githubUser: GitHubUser = await userResponse.json();
117
+
118
+      // Fetch GitHub user emails
119
+      const emailsResponse = await fetch('https://api.github.com/user/emails', {
120
+        headers: {
121
+          'Authorization': `Bearer ${tokenResponse.access_token}`,
122
+          'User-Agent': 'ZephyrFS/1.0',
123
+          'Accept': 'application/vnd.github.v3+json',
124
+        },
125
+      });
126
+
127
+      let primaryEmail = githubUser.email;
128
+      if (emailsResponse.ok) {
129
+        const emails: GitHubEmail[] = await emailsResponse.json();
130
+        const primary = emails.find(email => email.primary && email.verified);
131
+        if (primary) {
132
+          primaryEmail = primary.email;
133
+        }
134
+      }
135
+
136
+      if (!primaryEmail) {
137
+        // Redirect to frontend with error
138
+        const errorUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/error?error=no_email`;
139
+        return reply.redirect(errorUrl);
140
+      }
141
+
142
+      // Check if user already exists with this GitHub ID
143
+      let existingOAuthAccount = await db
144
+        .selectFrom('oauth_accounts')
145
+        .selectAll()
146
+        .where('provider', '=', 'github')
147
+        .where('provider_account_id', '=', githubUser.id.toString())
148
+        .executeTakeFirst();
149
+
150
+      let user: any;
151
+      let isNewUser = false;
152
+
153
+      if (existingOAuthAccount) {
154
+        // User exists, get their account
155
+        user = await db
156
+          .selectFrom('users')
157
+          .selectAll()
158
+          .where('id', '=', existingOAuthAccount.user_id)
159
+          .executeTakeFirst();
160
+
161
+        if (!user) {
162
+          throw new Error('OAuth account exists but user not found');
163
+        }
164
+
165
+        // Update OAuth account tokens
166
+        await db
167
+          .updateTable('oauth_accounts')
168
+          .set({
169
+            access_token: tokenResponse.access_token,
170
+            refresh_token: tokenResponse.refresh_token || null,
171
+            expires_at: tokenResponse.expires_in
172
+              ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
173
+              : null,
174
+            updated_at: new Date().toISOString(),
175
+          })
176
+          .where('id', '=', existingOAuthAccount.id)
177
+          .execute();
178
+
179
+        // Update user's GitHub info
180
+        await db
181
+          .updateTable('users')
182
+          .set({
183
+            github_username: githubUser.login,
184
+            github_avatar_url: githubUser.avatar_url,
185
+            last_login_at: new Date().toISOString(),
186
+            updated_at: new Date().toISOString(),
187
+          })
188
+          .where('id', '=', user.id)
189
+          .execute();
190
+
191
+      } else {
192
+        // Check if user exists with the email
193
+        user = await db
194
+          .selectFrom('users')
195
+          .selectAll()
196
+          .where('email', '=', primaryEmail)
197
+          .executeTakeFirst();
198
+
199
+        if (user) {
200
+          // Link GitHub to existing account
201
+          await db
202
+            .insertInto('oauth_accounts')
203
+            .values({
204
+              id: randomUUID(),
205
+              user_id: user.id,
206
+              provider: 'github',
207
+              provider_account_id: githubUser.id.toString(),
208
+              access_token: tokenResponse.access_token,
209
+              refresh_token: tokenResponse.refresh_token || null,
210
+              expires_at: tokenResponse.expires_in
211
+                ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
212
+                : null,
213
+              token_type: tokenResponse.token_type || 'bearer',
214
+              scope: Array.isArray(tokenResponse.scope)
215
+                ? tokenResponse.scope.join(' ')
216
+                : tokenResponse.scope || 'user:email read:user',
217
+              created_at: new Date().toISOString(),
218
+              updated_at: new Date().toISOString(),
219
+            })
220
+            .execute();
221
+
222
+          // Update user's GitHub info
223
+          await db
224
+            .updateTable('users')
225
+            .set({
226
+              github_id: githubUser.id.toString(),
227
+              github_username: githubUser.login,
228
+              github_avatar_url: githubUser.avatar_url,
229
+              last_login_at: new Date().toISOString(),
230
+              updated_at: new Date().toISOString(),
231
+            })
232
+            .where('id', '=', user.id)
233
+            .execute();
234
+
235
+        } else {
236
+          // New user - create temporary user and redirect to user type selection
237
+          isNewUser = true;
238
+          const tempUserId = randomUUID();
239
+
240
+          // Store temporary user data in database for user type selection
241
+          user = await db
242
+            .insertInto('users')
243
+            .values({
244
+              id: tempUserId,
245
+              email: primaryEmail,
246
+              username: githubUser.login,
247
+              user_type: 'backup', // Temporary, will be updated after type selection
248
+              email_verified: true, // GitHub email is considered verified
249
+              github_id: githubUser.id.toString(),
250
+              github_username: githubUser.login,
251
+              github_avatar_url: githubUser.avatar_url,
252
+              created_at: new Date().toISOString(),
253
+              updated_at: new Date().toISOString(),
254
+              profile_data: JSON.stringify({
255
+                temp: true,
256
+                githubName: githubUser.name,
257
+                needsUserTypeSelection: true,
258
+              }),
259
+            })
260
+            .returningAll()
261
+            .executeTakeFirstOrThrow();
262
+
263
+          // Create OAuth account
264
+          await db
265
+            .insertInto('oauth_accounts')
266
+            .values({
267
+              id: randomUUID(),
268
+              user_id: tempUserId,
269
+              provider: 'github',
270
+              provider_account_id: githubUser.id.toString(),
271
+              access_token: tokenResponse.access_token,
272
+              refresh_token: tokenResponse.refresh_token || null,
273
+              expires_at: tokenResponse.expires_in
274
+                ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString()
275
+                : null,
276
+              token_type: tokenResponse.token_type || 'bearer',
277
+              scope: Array.isArray(tokenResponse.scope)
278
+                ? tokenResponse.scope.join(' ')
279
+                : tokenResponse.scope || 'user:email read:user',
280
+              created_at: new Date().toISOString(),
281
+              updated_at: new Date().toISOString(),
282
+            })
283
+            .execute();
284
+
285
+          // Redirect to user type selection
286
+          const userTypeUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/select-type?temp_user=${tempUserId}&github=true`;
287
+          return reply.redirect(userTypeUrl);
288
+        }
289
+      }
290
+
291
+      // Create session for existing user
292
+      const sessionId = randomUUID();
293
+      const sessionToken = randomUUID();
294
+      const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
295
+
296
+      await db
297
+        .insertInto('user_sessions')
298
+        .values({
299
+          id: sessionId,
300
+          user_id: user.id,
301
+          session_token: sessionToken,
302
+          expires_at: expiresAt.toISOString(),
303
+          created_at: new Date().toISOString(),
304
+          last_access_at: new Date().toISOString(),
305
+          ip_address: request.ip,
306
+          user_agent: request.headers['user-agent'],
307
+        })
308
+        .execute();
309
+
310
+      // Generate tokens
311
+      const accessToken = fastify.jwt.sign(
312
+        { userId: user.id, username: user.username, sessionId },
313
+        { expiresIn: fastify.config.jwtExpiresIn }
314
+      );
315
+
316
+      // Redirect to frontend with token
317
+      const successUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/success?token=${accessToken}&new_user=${isNewUser}`;
318
+      return reply.redirect(successUrl);
319
+
320
+    } catch (error) {
321
+      fastify.log.error(error, 'GitHub OAuth callback failed');
322
+      const errorUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/auth/error?error=oauth_failed`;
323
+      return reply.redirect(errorUrl);
324
+    }
325
+  });
326
+
327
+  // Complete user type selection for GitHub OAuth users
328
+  fastify.post<{
329
+    Body: z.infer<typeof userTypeSelectionSchema>;
330
+  }>('/oauth/github/complete-registration', {
331
+    schema: { body: userTypeSelectionSchema },
332
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
333
+    const { userType, tempUserId } = request.body as z.infer<typeof userTypeSelectionSchema>;
334
+
335
+    try {
336
+      // Get temporary user
337
+      const tempUser = await db
338
+        .selectFrom('users')
339
+        .selectAll()
340
+        .where('id', '=', tempUserId)
341
+        .executeTakeFirst();
342
+
343
+      if (!tempUser) {
344
+        throw fastify.httpErrors.notFound('Temporary user not found');
345
+      }
346
+
347
+      const profileData = tempUser.profile_data ? JSON.parse(tempUser.profile_data) : {};
348
+      if (!profileData.temp || !profileData.needsUserTypeSelection) {
349
+        throw fastify.httpErrors.badRequest('User registration already completed');
350
+      }
351
+
352
+      // Update user with selected type
353
+      const user = await db
354
+        .updateTable('users')
355
+        .set({
356
+          user_type: userType,
357
+          profile_data: JSON.stringify({
358
+            ...profileData,
359
+            temp: false,
360
+            needsUserTypeSelection: false,
361
+          }),
362
+          updated_at: new Date().toISOString(),
363
+        })
364
+        .where('id', '=', tempUserId)
365
+        .returningAll()
366
+        .executeTakeFirstOrThrow();
367
+
368
+      // Initialize onboarding steps
369
+      const onboardingSteps = userType === 'volunteer'
370
+        ? ['user-type-selection', 'storage-setup', 'desktop-app', 'node-configuration']
371
+        : ['user-type-selection', 'backup-setup', 'first-upload'];
372
+
373
+      for (const step of onboardingSteps) {
374
+        await db
375
+          .insertInto('user_onboarding')
376
+          .values({
377
+            id: randomUUID(),
378
+            user_id: user.id,
379
+            step,
380
+            completed: step === 'user-type-selection',
381
+            data: step === 'user-type-selection' ? JSON.stringify({ userType }) : null,
382
+            completed_at: step === 'user-type-selection' ? new Date().toISOString() : null,
383
+            created_at: new Date().toISOString(),
384
+          })
385
+          .execute();
386
+      }
387
+
388
+      // Send welcome email
389
+      try {
390
+        await emailService.sendWelcomeEmail(user.email, userType, user.username || undefined);
391
+      } catch (emailError) {
392
+        fastify.log.error(emailError, 'Failed to send welcome email');
393
+      }
394
+
395
+      // Create session
396
+      const sessionId = randomUUID();
397
+      const sessionToken = randomUUID();
398
+      const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
399
+
400
+      await db
401
+        .insertInto('user_sessions')
402
+        .values({
403
+          id: sessionId,
404
+          user_id: user.id,
405
+          session_token: sessionToken,
406
+          expires_at: expiresAt.toISOString(),
407
+          created_at: new Date().toISOString(),
408
+          last_access_at: new Date().toISOString(),
409
+          ip_address: request.ip,
410
+          user_agent: request.headers['user-agent'],
411
+        })
412
+        .execute();
413
+
414
+      // Generate tokens
415
+      const accessToken = fastify.jwt.sign(
416
+        { userId: user.id, username: user.username, sessionId },
417
+        { expiresIn: fastify.config.jwtExpiresIn }
418
+      );
419
+
420
+      const refreshToken = fastify.jwt.sign(
421
+        { userId: user.id, sessionId, type: 'refresh' },
422
+        { expiresIn: fastify.config.jwtRefreshExpiresIn }
423
+      );
424
+
425
+      const response: AuthResponse = {
426
+        token: accessToken,
427
+        refreshToken,
428
+        expiresIn: 24 * 60 * 60,
429
+        user: toPublicUser(user),
430
+      };
431
+
432
+      return {
433
+        success: true,
434
+        message: 'Registration completed successfully! Welcome to ZephyrFS!',
435
+        auth: response,
436
+      };
437
+
438
+    } catch (error) {
439
+      if (error.statusCode) {
440
+        throw error;
441
+      }
442
+      fastify.log.error(error, 'GitHub OAuth registration completion failed');
443
+      throw fastify.httpErrors.internalServerError('Registration completion failed');
444
+    }
445
+  });
446
+
447
+  // Link GitHub account to existing user
448
+  fastify.post('/oauth/github/link', {
449
+    preHandler: fastify.authenticate,
450
+  }, async (request: FastifyRequest, reply: FastifyReply) => {
451
+    const user = request.user as { userId: string };
452
+
453
+    // Check if GitHub account already linked
454
+    const existingLink = await db
455
+      .selectFrom('oauth_accounts')
456
+      .selectAll()
457
+      .where('user_id', '=', user.userId)
458
+      .where('provider', '=', 'github')
459
+      .executeTakeFirst();
460
+
461
+    if (existingLink) {
462
+      throw fastify.httpErrors.conflict('GitHub account already linked to this user');
463
+    }
464
+
465
+    // Generate state for OAuth flow
466
+    const state = `link_${user.userId}_${randomUUID()}`;
467
+
468
+    return fastify.githubOAuth2.generateAuthorizationUri(request, reply, {
469
+      state,
470
+    });
471
+  });
472
+
473
+  // Unlink GitHub account
474
+  fastify.delete('/oauth/github/unlink', {
475
+    preHandler: fastify.authenticate,
476
+  }, async (request: FastifyRequest) => {
477
+    const user = request.user as { userId: string };
478
+
479
+    // Remove OAuth account
480
+    await db
481
+      .deleteFrom('oauth_accounts')
482
+      .where('user_id', '=', user.userId)
483
+      .where('provider', '=', 'github')
484
+      .execute();
485
+
486
+    // Clear GitHub info from user
487
+    await db
488
+      .updateTable('users')
489
+      .set({
490
+        github_id: null,
491
+        github_username: null,
492
+        github_avatar_url: null,
493
+        updated_at: new Date().toISOString(),
494
+      })
495
+      .where('id', '=', user.userId)
496
+      .execute();
497
+
498
+    return { success: true, message: 'GitHub account unlinked successfully' };
499
+  });
500
+}
server/src/services/email.tsadded
@@ -0,0 +1,371 @@
1
+import nodemailer from 'nodemailer';
2
+import type { Transporter } from 'nodemailer';
3
+
4
+export interface EmailConfig {
5
+  host: string;
6
+  port: number;
7
+  secure: boolean;
8
+  auth?: {
9
+    user: string;
10
+    pass: string;
11
+  };
12
+  from: string;
13
+}
14
+
15
+export class EmailService {
16
+  private transporter: Transporter;
17
+  private config: EmailConfig;
18
+
19
+  constructor(config: EmailConfig) {
20
+    this.config = config;
21
+
22
+    // For development, use Ethereal Email (fake SMTP service)
23
+    if (process.env.NODE_ENV === 'development' && !config.auth) {
24
+      // This will be setup asynchronously
25
+      this.setupDevelopmentTransporter();
26
+    } else {
27
+      this.transporter = nodemailer.createTransporter({
28
+        host: config.host,
29
+        port: config.port,
30
+        secure: config.secure,
31
+        auth: config.auth,
32
+      });
33
+    }
34
+  }
35
+
36
+  private async setupDevelopmentTransporter() {
37
+    try {
38
+      // Create Ethereal Email account for development
39
+      const testAccount = await nodemailer.createTestAccount();
40
+
41
+      this.transporter = nodemailer.createTransporter({
42
+        host: 'smtp.ethereal.email',
43
+        port: 587,
44
+        secure: false,
45
+        auth: {
46
+          user: testAccount.user,
47
+          pass: testAccount.pass,
48
+        },
49
+      });
50
+
51
+      console.log('Development email transporter created with Ethereal Email');
52
+      console.log('Preview URLs will be logged when emails are sent');
53
+    } catch (error) {
54
+      console.error('Failed to create development email transporter:', error);
55
+      // Fallback to console logging
56
+      this.transporter = {
57
+        sendMail: async (options: any) => {
58
+          console.log('Email would be sent:', options);
59
+          return { messageId: 'dev-' + Date.now() };
60
+        },
61
+      } as any;
62
+    }
63
+  }
64
+
65
+  async sendEmailVerification(to: string, token: string, username?: string): Promise<void> {
66
+    const verificationUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/verify-email?token=${token}`;
67
+
68
+    const html = `
69
+      <!DOCTYPE html>
70
+      <html>
71
+        <head>
72
+          <meta charset="utf-8">
73
+          <title>Verify your ZephyrFS account</title>
74
+          <style>
75
+            body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
76
+            .container { max-width: 600px; margin: 0 auto; padding: 20px; }
77
+            .header { background: #007bff; color: white; padding: 20px; text-align: center; }
78
+            .content { padding: 30px; background: #f9f9f9; }
79
+            .button {
80
+              display: inline-block;
81
+              background: #007bff;
82
+              color: white;
83
+              padding: 12px 30px;
84
+              text-decoration: none;
85
+              border-radius: 5px;
86
+              margin: 20px 0;
87
+            }
88
+            .footer { padding: 20px; text-align: center; color: #666; font-size: 14px; }
89
+          </style>
90
+        </head>
91
+        <body>
92
+          <div class="container">
93
+            <div class="header">
94
+              <h1>Welcome to ZephyrFS!</h1>
95
+            </div>
96
+            <div class="content">
97
+              <h2>Verify Your Email Address</h2>
98
+              <p>Hello${username ? ` ${username}` : ''}!</p>
99
+              <p>Thank you for signing up for ZephyrFS, the secure and decentralized file backup system.</p>
100
+              <p>To complete your registration and start using ZephyrFS, please verify your email address by clicking the button below:</p>
101
+
102
+              <div style="text-align: center;">
103
+                <a href="${verificationUrl}" class="button">Verify Email Address</a>
104
+              </div>
105
+
106
+              <p>Or copy and paste this link into your browser:</p>
107
+              <p style="word-break: break-all; background: #eee; padding: 10px; border-radius: 3px;">
108
+                ${verificationUrl}
109
+              </p>
110
+
111
+              <p><strong>This verification link will expire in 24 hours.</strong></p>
112
+
113
+              <p>If you didn't create an account with ZephyrFS, you can safely ignore this email.</p>
114
+            </div>
115
+            <div class="footer">
116
+              <p>© 2024 ZephyrFS. All rights reserved.</p>
117
+              <p>Secure, decentralized file backup for everyone.</p>
118
+            </div>
119
+          </div>
120
+        </body>
121
+      </html>
122
+    `;
123
+
124
+    const text = `
125
+      Welcome to ZephyrFS!
126
+
127
+      Hello${username ? ` ${username}` : ''}!
128
+
129
+      Thank you for signing up for ZephyrFS, the secure and decentralized file backup system.
130
+
131
+      To complete your registration and start using ZephyrFS, please verify your email address by visiting this link:
132
+
133
+      ${verificationUrl}
134
+
135
+      This verification link will expire in 24 hours.
136
+
137
+      If you didn't create an account with ZephyrFS, you can safely ignore this email.
138
+
139
+      © 2024 ZephyrFS. All rights reserved.
140
+    `;
141
+
142
+    const result = await this.transporter.sendMail({
143
+      from: this.config.from || 'ZephyrFS <noreply@zephyrfs.org>',
144
+      to,
145
+      subject: 'Verify your ZephyrFS account',
146
+      text,
147
+      html,
148
+    });
149
+
150
+    // In development, log preview URL
151
+    if (process.env.NODE_ENV === 'development') {
152
+      const previewUrl = nodemailer.getTestMessageUrl(result);
153
+      if (previewUrl) {
154
+        console.log('Email verification preview:', previewUrl);
155
+      }
156
+    }
157
+  }
158
+
159
+  async sendPasswordReset(to: string, token: string, username?: string): Promise<void> {
160
+    const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
161
+
162
+    const html = `
163
+      <!DOCTYPE html>
164
+      <html>
165
+        <head>
166
+          <meta charset="utf-8">
167
+          <title>Reset your ZephyrFS password</title>
168
+          <style>
169
+            body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
170
+            .container { max-width: 600px; margin: 0 auto; padding: 20px; }
171
+            .header { background: #dc3545; color: white; padding: 20px; text-align: center; }
172
+            .content { padding: 30px; background: #f9f9f9; }
173
+            .button {
174
+              display: inline-block;
175
+              background: #dc3545;
176
+              color: white;
177
+              padding: 12px 30px;
178
+              text-decoration: none;
179
+              border-radius: 5px;
180
+              margin: 20px 0;
181
+            }
182
+            .footer { padding: 20px; text-align: center; color: #666; font-size: 14px; }
183
+            .warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }
184
+          </style>
185
+        </head>
186
+        <body>
187
+          <div class="container">
188
+            <div class="header">
189
+              <h1>Password Reset Request</h1>
190
+            </div>
191
+            <div class="content">
192
+              <h2>Reset Your Password</h2>
193
+              <p>Hello${username ? ` ${username}` : ''}!</p>
194
+              <p>We received a request to reset the password for your ZephyrFS account.</p>
195
+
196
+              <div class="warning">
197
+                <strong>⚠️ Security Notice:</strong> If you didn't request this password reset, please ignore this email. Your account is safe.
198
+              </div>
199
+
200
+              <p>To reset your password, click the button below:</p>
201
+
202
+              <div style="text-align: center;">
203
+                <a href="${resetUrl}" class="button">Reset Password</a>
204
+              </div>
205
+
206
+              <p>Or copy and paste this link into your browser:</p>
207
+              <p style="word-break: break-all; background: #eee; padding: 10px; border-radius: 3px;">
208
+                ${resetUrl}
209
+              </p>
210
+
211
+              <p><strong>This reset link will expire in 1 hour.</strong></p>
212
+
213
+              <p>After clicking the link, you'll be able to create a new password for your account.</p>
214
+            </div>
215
+            <div class="footer">
216
+              <p>© 2024 ZephyrFS. All rights reserved.</p>
217
+              <p>If you have security concerns, contact us immediately.</p>
218
+            </div>
219
+          </div>
220
+        </body>
221
+      </html>
222
+    `;
223
+
224
+    const text = `
225
+      Password Reset Request
226
+
227
+      Hello${username ? ` ${username}` : ''}!
228
+
229
+      We received a request to reset the password for your ZephyrFS account.
230
+
231
+      If you didn't request this password reset, please ignore this email. Your account is safe.
232
+
233
+      To reset your password, visit this link:
234
+
235
+      ${resetUrl}
236
+
237
+      This reset link will expire in 1 hour.
238
+
239
+      After clicking the link, you'll be able to create a new password for your account.
240
+
241
+      © 2024 ZephyrFS. All rights reserved.
242
+    `;
243
+
244
+    const result = await this.transporter.sendMail({
245
+      from: this.config.from || 'ZephyrFS Security <security@zephyrfs.org>',
246
+      to,
247
+      subject: 'Reset your ZephyrFS password',
248
+      text,
249
+      html,
250
+    });
251
+
252
+    // In development, log preview URL
253
+    if (process.env.NODE_ENV === 'development') {
254
+      const previewUrl = nodemailer.getTestMessageUrl(result);
255
+      if (previewUrl) {
256
+        console.log('Password reset preview:', previewUrl);
257
+      }
258
+    }
259
+  }
260
+
261
+  async sendWelcomeEmail(to: string, userType: 'backup' | 'volunteer', username?: string): Promise<void> {
262
+    const isVolunteer = userType === 'volunteer';
263
+    const dashboardUrl = `${process.env.FRONTEND_URL || 'http://localhost:3000'}/dashboard`;
264
+
265
+    const html = `
266
+      <!DOCTYPE html>
267
+      <html>
268
+        <head>
269
+          <meta charset="utf-8">
270
+          <title>Welcome to ZephyrFS!</title>
271
+          <style>
272
+            body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
273
+            .container { max-width: 600px; margin: 0 auto; padding: 20px; }
274
+            .header { background: #28a745; color: white; padding: 20px; text-align: center; }
275
+            .content { padding: 30px; background: #f9f9f9; }
276
+            .button {
277
+              display: inline-block;
278
+              background: #28a745;
279
+              color: white;
280
+              padding: 12px 30px;
281
+              text-decoration: none;
282
+              border-radius: 5px;
283
+              margin: 20px 0;
284
+            }
285
+            .footer { padding: 20px; text-align: center; color: #666; font-size: 14px; }
286
+            .feature { background: white; padding: 15px; margin: 10px 0; border-left: 4px solid #28a745; }
287
+          </style>
288
+        </head>
289
+        <body>
290
+          <div class="container">
291
+            <div class="header">
292
+              <h1>🎉 Welcome to ZephyrFS!</h1>
293
+            </div>
294
+            <div class="content">
295
+              <h2>Your account is ready!</h2>
296
+              <p>Hello${username ? ` ${username}` : ''}!</p>
297
+              <p>Welcome to ZephyrFS! Your email has been verified and your account is now active.</p>
298
+
299
+              ${isVolunteer ? `
300
+                <h3>🤝 Thank you for volunteering!</h3>
301
+                <p>As a storage volunteer, you're helping build a decentralized, secure backup network that benefits everyone.</p>
302
+
303
+                <div class="feature">
304
+                  <strong>📁 Safe Storage Allocation</strong><br>
305
+                  Choose how much disk space to contribute safely without risking your system.
306
+                </div>
307
+
308
+                <div class="feature">
309
+                  <strong>🔒 Zero-Knowledge Security</strong><br>
310
+                  All stored data is encrypted - you can't see what's stored on your system.
311
+                </div>
312
+
313
+                <div class="feature">
314
+                  <strong>💰 Earn Rewards</strong><br>
315
+                  Get compensated for providing reliable storage to the network.
316
+                </div>
317
+
318
+                <p>Ready to get started? Use our desktop app to set up your storage node:</p>
319
+              ` : `
320
+                <h3>☁️ Secure Backup Made Simple</h3>
321
+                <p>Your files will be encrypted, distributed, and safely backed up across our decentralized network.</p>
322
+
323
+                <div class="feature">
324
+                  <strong>🔐 Military-Grade Encryption</strong><br>
325
+                  Your files are encrypted before leaving your device.
326
+                </div>
327
+
328
+                <div class="feature">
329
+                  <strong>🌐 Distributed Storage</strong><br>
330
+                  Files are split and stored across multiple secure nodes.
331
+                </div>
332
+
333
+                <div class="feature">
334
+                  <strong>⚡ Simple as Google Drive</strong><br>
335
+                  Drag and drop to backup. That's it!
336
+                </div>
337
+
338
+                <p>Ready to start backing up your files?</p>
339
+              `}
340
+
341
+              <div style="text-align: center;">
342
+                <a href="${dashboardUrl}" class="button">Get Started</a>
343
+              </div>
344
+
345
+              <p>If you have any questions or need help getting started, our community is here to support you!</p>
346
+            </div>
347
+            <div class="footer">
348
+              <p>© 2024 ZephyrFS. All rights reserved.</p>
349
+              <p>Building the future of secure, decentralized storage.</p>
350
+            </div>
351
+          </div>
352
+        </body>
353
+      </html>
354
+    `;
355
+
356
+    const result = await this.transporter.sendMail({
357
+      from: this.config.from || 'ZephyrFS <welcome@zephyrfs.org>',
358
+      to,
359
+      subject: '🎉 Welcome to ZephyrFS - Your account is ready!',
360
+      html,
361
+    });
362
+
363
+    // In development, log preview URL
364
+    if (process.env.NODE_ENV === 'development') {
365
+      const previewUrl = nodemailer.getTestMessageUrl(result);
366
+      if (previewUrl) {
367
+        console.log('Welcome email preview:', previewUrl);
368
+      }
369
+    }
370
+  }
371
+}