UNPKG

@codehance/rapid-stack

Version:

A modern full-stack development toolkit for rapid application development

621 lines (524 loc) 21.4 kB
const Generator = require('yeoman-generator'); const path = require('path'); const fs = require('fs'); const { handlePrompt } = require('../../lib/utils'); class AuthGenerator extends Generator { constructor(args, opts) { super(args, opts); // Add force option this.option('force', { type: Boolean, description: 'Force overwrite files without confirmation', default: true }); // Add debug option this.option('debug', { desc: 'Enable debug mode', type: Boolean, default: false }); // Fixed values for folder name and destination path this.folderName = 'user_mutations'; this.destinationPath = 'backend/app/graphql/mutations'; this.serviceDestinationPath = 'backend/app/services'; this.mutationTypePath = 'backend/app/graphql/types/mutation_type.rb'; this.backendPath = 'backend'; // Fields to exclude from the User model when generating arguments this.excludedFields = [ 'encrypted_password', 'reset_password_token', 'reset_password_sent_at', 'remember_created_at', 'reset_password_token_expires_at', 'role', 'created_at', 'updated_at', '_id', 'id' ]; // Track modified files this.modifiedFiles = []; } _getUserModelFields() { const userModelPath = path.join(process.cwd(), 'backend/app/models/user.rb'); if (!fs.existsSync(userModelPath)) { this.log('User model not found at: ' + userModelPath); return []; } const content = fs.readFileSync(userModelPath, 'utf8'); const fields = []; // Match field definitions in the model const fieldRegex = /field\s+:(\w+),\s+type:\s+(\w+)(?:,\s+default:\s+([^,\n]+))?/g; let match; while ((match = fieldRegex.exec(content)) !== null) { const [, fieldName, fieldType] = match; // Skip excluded fields if (!this.excludedFields.includes(fieldName)) { fields.push({ name: fieldName, type: this._convertRubyTypeToGraphQL(fieldType) }); } } // Add password field which might not be defined as a field in the model if (!fields.some(f => f.name === 'password')) { fields.push({ name: 'password', type: 'String' }); } return fields; } _convertRubyTypeToGraphQL(rubyType) { const typeMap = { 'String': 'String', 'Integer': 'Int', 'Float': 'Float', 'Boolean': 'Boolean', 'Time': 'GraphQL::Types::ISO8601DateTime', 'Date': 'GraphQL::Types::ISO8601Date', 'DateTime': 'GraphQL::Types::ISO8601DateTime', 'Hash': 'GraphQL::Types::JSON', 'Array': 'GraphQL::Types::JSON' }; return typeMap[rubyType] || 'String'; } async prompting() { this.answers = await handlePrompt(this, [ { type: 'confirm', name: 'confirmInstallation', message: 'Are you sure you want to set up authentication?', default: false }, { type: 'confirm', name: 'includeSignIn', message: 'Include sign in mutation?', default: true }, { type: 'confirm', name: 'includeLogout', message: 'Include logout mutation?', default: true }, { type: 'confirm', name: 'includePasswordReset', message: 'Include password reset mutation?', default: true }, { type: 'confirm', name: 'includeUpdatePassword', message: 'Include update password mutation?', default: true }, { type: 'confirm', name: 'includeOtpRequest', message: 'Include OTP request mutation?', default: true }, { type: 'confirm', name: 'includeSetupTwoFactor', message: 'Include setup two-factor mutation?', default: true }, { type: 'confirm', name: 'includeVerifyTwoFactorSetup', message: 'Include verify two-factor setup mutation?', default: true } ]); if (!this.answers.confirmInstallation) { this.log('Operation cancelled.'); process.exit(0); } } async initializing() { // Check if User model exists const userModelPath = path.join('backend', 'app', 'models', 'user.rb'); if (!fs.existsSync(userModelPath)) { this.log('❌ Error: User model not found. Please create a User model first.'); this.log('You can create a User model by running:'); this.log('rapid schema:create'); this.log('rapid schema:run'); process.exit(1); } this.log('✓ Found User model'); this.log('This generator will set up authentication in your Rails API application.'); if (this.options.force) { this.log('Force mode enabled - files will be overwritten without confirmation'); } // Create models directory if it doesn't exist const modelsPath = 'backend/app/models'; if (!fs.existsSync(modelsPath)) { fs.mkdirSync(modelsPath, { recursive: true }); } // Create JWT denylist model this.log('Creating JWT denylist model...'); try { const jwtDenylistTemplate = fs.readFileSync(this.templatePath('models', 'jwt_denylist.rb.erb'), 'utf8'); fs.writeFileSync(path.join(modelsPath, 'jwt_denylist.rb'), jwtDenylistTemplate); this.log('JWT denylist model created successfully'); } catch (error) { this.log(`Error creating JWT denylist model: ${error.message}`); throw new Error(`Failed to create JWT denylist model: ${error.message}`); } // Create OTP model this.log('Creating OTP model...'); try { const otpTemplate = fs.readFileSync(this.templatePath('models', 'otp.rb.erb'), 'utf8'); fs.writeFileSync(path.join(modelsPath, 'otp.rb'), otpTemplate); this.log('OTP model created successfully'); } catch (error) { this.log(`Error creating OTP model: ${error.message}`); throw new Error(`Failed to create OTP model: ${error.message}`); } // Create TwoFactor model this.log('Creating TwoFactor model...'); try { const twoFactorTemplate = fs.readFileSync(this.templatePath('models', 'two_factor.rb.erb'), 'utf8'); fs.writeFileSync(path.join(modelsPath, 'two_factor.rb'), twoFactorTemplate); this.log('TwoFactor model created successfully'); } catch (error) { this.log(`Error creating TwoFactor model: ${error.message}`); throw new Error(`Failed to create TwoFactor model: ${error.message}`); } } writing() { // Generate mutations const destPath = path.join(this.destinationPath, this.folderName); // Create the destination directory if it doesn't exist if (!fs.existsSync(destPath)) { this.log(`Creating directory: ${destPath}`); fs.mkdirSync(destPath, { recursive: true }); } // Generate the selected mutation files this._generateMutationFiles(destPath); // Always generate AuthService this._generateAuthService(); // Update the mutation_type.rb file this._updateMutationType(); // Update the user_type.rb file with two-factor field this._updateUserType(); // Update the User model with two-factor relationship this._updateUserModel(); // Copy two-factor authentication types this._copyTwoFactorTypes(); } _generateMutationFiles(destPath) { const mutations = [ { name: 'sign_in', include: this.answers.includeSignIn }, { name: 'logout', include: this.answers.includeLogout }, { name: 'password_reset', include: this.answers.includePasswordReset }, { name: 'update_password', include: this.answers.includeUpdatePassword }, { name: 'otp_request', include: this.answers.includeOtpRequest }, { name: 'setup_two_factor', include: this.answers.includeSetupTwoFactor }, { name: 'verify_two_factor_setup', include: this.answers.includeVerifyTwoFactorSetup } ]; mutations.forEach(mutation => { if (mutation.include) { this.log(`Generating ${mutation.name}.rb`); // Convert snake_case to CamelCase for class name const className = mutation.name .split('_') .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); this.fs.copyTpl( this.templatePath('mutations', `${mutation.name}.rb.ejs`), path.join(destPath, `${mutation.name}.rb`), { className, moduleName: this.folderName .split('_') .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(''), fields: this._getUserModelFields() }, {}, { force: this.options.force } ); } }); } _generateAuthService() { const servicePath = path.join(this.serviceDestinationPath, 'auth_service.rb'); this.log(`Generating auth_service.rb`); // Create the services directory if it doesn't exist if (!fs.existsSync(this.serviceDestinationPath)) { this.log(`Creating directory: ${this.serviceDestinationPath}`); fs.mkdirSync(this.serviceDestinationPath, { recursive: true }); } // Generate the AuthService this.fs.copyTpl( this.templatePath('services', 'auth_service.rb.ejs'), servicePath, { userFields: this._getUserModelFields() }, {}, { force: this.options.force } ); } _updateMutationType() { const mutationTypePath = path.join(process.cwd(), this.mutationTypePath); if (!fs.existsSync(mutationTypePath)) { this.log.error(`Mutation type file not found at: ${mutationTypePath}`); return; } this.log(`Updating mutation_type.rb with auth mutations`); let content = fs.readFileSync(mutationTypePath, 'utf8'); // First fix any malformed end statements on the same line as fields content = content.replace(/(\S+.*?)\s+end(\s*)$/gm, '$1\n end$2'); // Split the content into lines for better structure handling let lines = content.split('\n'); // Find the class definition const classLineIndex = lines.findIndex(line => line.includes('class MutationType')); if (classLineIndex === -1) { this.log.error('Could not find class definition in mutation_type.rb'); return; } // Find the end of the class const classEndIndex = lines.findIndex((line, index) => index > classLineIndex && line.trim() === 'end' ); if (classEndIndex === -1) { this.log.error('Could not find end of class in mutation_type.rb'); return; } // Get the mutations to add const mutations = [ { name: 'signIn', snakeName: 'sign_in', include: this.answers.includeSignIn }, { name: 'logout', snakeName: 'logout', include: this.answers.includeLogout }, { name: 'passwordReset', snakeName: 'password_reset', include: this.answers.includePasswordReset }, { name: 'updatePassword', snakeName: 'update_password', include: this.answers.includeUpdatePassword }, { name: 'otpRequest', snakeName: 'otp_request', include: this.answers.includeOtpRequest }, { name: 'setupTwoFactor', snakeName: 'setup_two_factor', include: this.answers.includeSetupTwoFactor }, { name: 'verifyTwoFactorSetup', snakeName: 'verify_two_factor_setup', include: this.answers.includeVerifyTwoFactorSetup } ]; // Check if there's a user mutations section comment const userMutationsCommentIndex = lines.findIndex(line => line.includes('# User-related mutations') || line.includes('# User mutations') ); // If there's no user mutations comment, add one before adding the mutations if (userMutationsCommentIndex === -1) { lines.splice(classLineIndex + 1, 0, ' # User-related mutations'); } // Add each mutation if it doesn't already exist mutations.forEach(mutation => { if (mutation.include) { const moduleName = this.folderName .split('_') .map(part => part.charAt(0).toUpperCase() + part.slice(1)) .join(''); const mutationLine = ` field :${mutation.name}, mutation: Mutations::${moduleName}::${this._snakeToCamelCase(mutation.snakeName)}`; // Check if this mutation already exists const mutationExists = lines.some(line => line.includes(`field :${mutation.name}`) && line.includes(`Mutations::${moduleName}::${this._snakeToCamelCase(mutation.snakeName)}`) ); if (!mutationExists) { // Find the best place to insert the mutation let insertIndex; if (userMutationsCommentIndex !== -1) { // Insert after the user mutations comment insertIndex = userMutationsCommentIndex + 1; } else { // Insert after the class definition insertIndex = classLineIndex + 2; } // Insert the mutation lines.splice(insertIndex, 0, mutationLine); this.log(`Added ${mutation.name} mutation to mutation_type.rb`); } else { this.log(`Mutation ${mutation.name} already exists in mutation_type.rb`); } } }); // Write the updated content back to the file fs.writeFileSync(mutationTypePath, lines.join('\n'), 'utf8'); } _snakeToCamelCase(str) { return str .split('_') .map((part, index) => { return part.charAt(0).toUpperCase() + part.slice(1); }) .join(''); } _updateUserType() { const userTypePath = path.join(this.backendPath, 'app', 'graphql', 'types', 'user_type.rb'); if (!fs.existsSync(userTypePath)) { this.log('❌ User type file not found'); return; } let content = fs.readFileSync(userTypePath, 'utf8'); const twoFactorLine = 'field :two_factor, Types::TwoFactorType, null: true # has_one'; // Check if the two-factor field already exists if (content.includes(twoFactorLine)) { this.log('✓ Two-factor field already exists in user_type.rb'); return; } // Define default indentation for fields const indent = ' '; // Try to locate a "# Relationships" header const relationshipsRegex = /^(\s*# Relationships\s*)$/m; const relationshipsMatch = relationshipsRegex.exec(content); if (relationshipsMatch) { // Determine the end of the header line const headerIndex = relationshipsMatch.index; const headerEnd = headerIndex + relationshipsMatch[0].length; // Get the text after the header and split into lines const afterHeader = content.slice(headerEnd); const afterHeaderLines = afterHeader.split('\n'); // Skip any blank lines immediately following the header let insertIndex = headerEnd; for (let i = 0; i < afterHeaderLines.length; i++) { if (afterHeaderLines[i].trim() === '') { const nextNewline = content.indexOf('\n', insertIndex); if (nextNewline === -1) break; insertIndex = nextNewline + 1; } else { break; } } // Use the indentation of the line we're inserting into (or default if none found) const insertionIndentMatch = content.slice(insertIndex).match(/^(\s*)/); const newLineIndent = insertionIndentMatch ? insertionIndentMatch[1] : indent; // Insert the two-factor field without adding an extra blank line const newFieldLine = newLineIndent + twoFactorLine + '\n'; content = content.slice(0, insertIndex) + newFieldLine + content.slice(insertIndex); } else { // If no Relationships header, insert after the last field declaration. const fieldRegex = /^(\s*field\s+:[^\n]+)$/gm; let lastMatch = null; let match; while ((match = fieldRegex.exec(content)) !== null) { lastMatch = match; } if (lastMatch) { const lastFieldEnd = lastMatch.index + lastMatch[0].length; // Use the indentation of the last field for the new line. const fieldIndentMatch = lastMatch[0].match(/^(\s*)/); const fieldIndent = fieldIndentMatch ? fieldIndentMatch[1] : indent; content = content.slice(0, lastFieldEnd) + '\n' + fieldIndent + twoFactorLine + content.slice(lastFieldEnd); } else { // Fallback: insert before the closing 'end' of the class. const classEndIndex = content.lastIndexOf('end'); if (classEndIndex !== -1) { // Find the indentation on the 'end' line. const endLineIndentMatch = content.slice(classEndIndex).match(/^(\s*)/); const endIndent = endLineIndentMatch ? endLineIndentMatch[1] : indent; content = content.slice(0, classEndIndex) + endIndent + twoFactorLine + '\n' + content.slice(classEndIndex); } } } fs.writeFileSync(userTypePath, content); this.log('✓ Added two-factor field to user_type.rb'); } _updateUserModel() { const userModelPath = path.join(this.backendPath, 'app', 'models', 'user.rb'); if (!fs.existsSync(userModelPath)) { this.log('❌ User model not found. Skipping two-factor relationship addition.'); return; } let content = fs.readFileSync(userModelPath, 'utf8'); // Check if the relationship already exists if (content.includes('has_one :two_factor')) { this.log('✓ Two-factor relationship already exists in User model'); return; } // Define the two-space indentation string const indent = ' '; // Try to locate the Relationships section const relationshipsIndex = content.indexOf('# Relationships'); if (relationshipsIndex !== -1) { // Find the end of the Relationships header line const headerEndIndex = content.indexOf('\n', relationshipsIndex); const insertIndex = headerEndIndex !== -1 ? headerEndIndex + 1 : content.length; // Insert the two-factor relationship using proper indentation const newContent = content.slice(0, insertIndex) + indent + 'has_one :two_factor\n' + content.slice(insertIndex); fs.writeFileSync(userModelPath, newContent); this.log('✓ Added two-factor relationship to User model in Relationships section'); return; } // If no Relationships section is found, find the last field definition const fieldRegex = /^\s*field\s+:.*$/gm; let lastFieldIndex = -1; let match; while ((match = fieldRegex.exec(content)) !== null) { lastFieldIndex = match.index; } if (lastFieldIndex === -1) { this.log('❌ Could not find any field definitions in User model'); return; } // Find the end of the last field line const lastFieldEndIndex = content.indexOf('\n', lastFieldIndex); if (lastFieldEndIndex === -1) { this.log('❌ Could not find end of last field definition'); return; } // Insert a new Relationships section and the two-factor relationship after the last field const newContent = content.slice(0, lastFieldEndIndex + 1) + '\n' + indent + '# Relationships\n' + indent + 'has_one :two_factor\n' + content.slice(lastFieldEndIndex + 1); fs.writeFileSync(userModelPath, newContent); this.log('✓ Added two-factor relationship to User model after fields'); } _copyTwoFactorTypes() { const sourceTypesPath = this.templatePath('types'); const destTypesPath = path.join(this.backendPath, 'app', 'graphql', 'types'); // Ensure destination directory exists if (!fs.existsSync(destTypesPath)) { fs.mkdirSync(destTypesPath, { recursive: true }); } // Copy two_factor_type.rb const twoFactorTypeSource = path.join(sourceTypesPath, 'two_factor_type.rb.ejs'); const twoFactorTypeDest = path.join(destTypesPath, 'two_factor_type.rb'); if (fs.existsSync(twoFactorTypeSource)) { this.fs.copyTpl( twoFactorTypeSource, twoFactorTypeDest, {} ); this.log('✓ Copied two_factor_type.rb'); } // Copy setup_two_factor_input.rb const setupTwoFactorInputSource = path.join(sourceTypesPath, 'setup_two_factor_input.rb.ejs'); const setupTwoFactorInputDest = path.join(destTypesPath, 'setup_two_factor_input.rb'); if (fs.existsSync(setupTwoFactorInputSource)) { this.fs.copyTpl( setupTwoFactorInputSource, setupTwoFactorInputDest, {} ); this.log('✓ Copied setup_two_factor_input.rb'); } // Copy verify_two_factor_setup_input.rb const verifyTwoFactorSetupInputSource = path.join(sourceTypesPath, 'verify_two_factor_setup_input.rb.ejs'); const verifyTwoFactorSetupInputDest = path.join(destTypesPath, 'verify_two_factor_setup_input.rb'); if (fs.existsSync(verifyTwoFactorSetupInputSource)) { this.fs.copyTpl( verifyTwoFactorSetupInputSource, verifyTwoFactorSetupInputDest, {} ); this.log('✓ Copied verify_two_factor_setup_input.rb'); } } } module.exports = AuthGenerator;