Skip to main content

romance_core/addon/
oauth.rs

1use crate::addon::Addon;
2use anyhow::Result;
3use std::path::Path;
4
5pub struct OauthAddon {
6    pub provider: String,
7}
8
9impl Addon for OauthAddon {
10    fn name(&self) -> &str {
11        "oauth"
12    }
13
14    fn check_prerequisites(&self, project_root: &Path) -> Result<()> {
15        super::check_romance_project(project_root)?;
16        super::check_auth_exists(project_root)
17    }
18
19    fn is_already_installed(&self, project_root: &Path) -> bool {
20        project_root.join("backend/src/oauth.rs").exists()
21    }
22
23    fn install(&self, project_root: &Path) -> Result<()> {
24        install_oauth(project_root, &self.provider)
25    }
26
27    fn uninstall(&self, project_root: &Path) -> Result<()> {
28        use colored::Colorize;
29
30        println!("{}", "Uninstalling OAuth...".bold());
31
32        // Delete files
33        if super::remove_file_if_exists(&project_root.join("backend/src/oauth.rs"))? {
34            println!("  {} backend/src/oauth.rs", "delete".red());
35        }
36        if super::remove_file_if_exists(&project_root.join("backend/src/handlers/oauth.rs"))? {
37            println!("  {} backend/src/handlers/oauth.rs", "delete".red());
38        }
39        if super::remove_file_if_exists(&project_root.join("backend/src/routes/oauth.rs"))? {
40            println!("  {} backend/src/routes/oauth.rs", "delete".red());
41        }
42        if super::remove_file_if_exists(
43            &project_root.join("frontend/src/features/auth/OAuthButton.tsx"),
44        )? {
45            println!(
46                "  {} frontend/src/features/auth/OAuthButton.tsx",
47                "delete".red()
48            );
49        }
50
51        // Remove mod declaration from main.rs
52        super::remove_mod_from_main(project_root, "oauth")?;
53
54        // Remove from handlers/mod.rs
55        super::remove_line_from_file(
56            &project_root.join("backend/src/handlers/mod.rs"),
57            "pub mod oauth;",
58        )?;
59
60        // Remove from routes/mod.rs
61        super::remove_line_from_file(
62            &project_root.join("backend/src/routes/mod.rs"),
63            "pub mod oauth;",
64        )?;
65        super::remove_line_from_file(
66            &project_root.join("backend/src/routes/mod.rs"),
67            ".merge(oauth::router())",
68        )?;
69
70        // Regenerate AI context
71        crate::ai_context::regenerate(project_root).ok();
72
73        println!();
74        println!("{}", "OAuth uninstalled successfully.".green().bold());
75
76        Ok(())
77    }
78
79    fn dependencies(&self) -> Vec<&str> {
80        vec!["auth"]
81    }
82}
83
84fn install_oauth(project_root: &Path, provider: &str) -> Result<()> {
85    use crate::template::TemplateEngine;
86    use crate::utils;
87    use colored::Colorize;
88    use heck::ToPascalCase;
89    use tera::Context;
90
91    let valid_providers = ["google", "github", "discord"];
92    if !valid_providers.contains(&provider) {
93        anyhow::bail!(
94            "Unsupported OAuth provider '{}'. Supported: {}",
95            provider,
96            valid_providers.join(", ")
97        );
98    }
99
100    println!(
101        "{}",
102        format!("Installing OAuth ({})...", provider).bold()
103    );
104
105    let engine = TemplateEngine::new()?;
106    let timestamp = crate::generator::migration::next_timestamp();
107
108    let mut ctx = Context::new();
109    ctx.insert("provider", provider);
110    ctx.insert("provider_pascal", &provider.to_pascal_case());
111    ctx.insert("timestamp", &timestamp);
112
113    // Generate OAuth module
114    let content = engine.render("addon/oauth/oauth.rs.tera", &ctx)?;
115    utils::write_file(&project_root.join("backend/src/oauth.rs"), &content)?;
116    println!("  {} backend/src/oauth.rs", "create".green());
117
118    // Generate OAuth handlers
119    let content = engine.render("addon/oauth/oauth_handlers.rs.tera", &ctx)?;
120    utils::write_file(
121        &project_root.join("backend/src/handlers/oauth.rs"),
122        &content,
123    )?;
124    println!("  {} backend/src/handlers/oauth.rs", "create".green());
125
126    // Generate OAuth routes
127    let content = engine.render("addon/oauth/oauth_routes.rs.tera", &ctx)?;
128    utils::write_file(
129        &project_root.join("backend/src/routes/oauth.rs"),
130        &content,
131    )?;
132    println!("  {} backend/src/routes/oauth.rs", "create".green());
133
134    // Generate migration to add oauth columns to users table
135    let content = engine.render("addon/oauth/oauth_migration.rs.tera", &ctx)?;
136    let migration_module = format!("m{}_add_oauth_to_users", timestamp);
137    utils::write_file(
138        &project_root.join(format!("backend/migration/src/{}.rs", migration_module)),
139        &content,
140    )?;
141    println!(
142        "  {} backend/migration/src/{}.rs",
143        "create".green(),
144        migration_module
145    );
146
147    // Generate frontend OAuth button
148    let content = engine.render("addon/oauth/OAuthButton.tsx.tera", &ctx)?;
149    utils::write_file(
150        &project_root.join("frontend/src/features/auth/OAuthButton.tsx"),
151        &content,
152    )?;
153    println!(
154        "  {} frontend/src/features/auth/OAuthButton.tsx",
155        "create".green()
156    );
157
158    // Register modules
159    let mods_marker = "// === ROMANCE:MODS ===";
160    utils::insert_at_marker(
161        &project_root.join("backend/src/handlers/mod.rs"),
162        mods_marker,
163        "pub mod oauth;",
164    )?;
165    utils::insert_at_marker(
166        &project_root.join("backend/src/routes/mod.rs"),
167        mods_marker,
168        "pub mod oauth;",
169    )?;
170    utils::insert_at_marker(
171        &project_root.join("backend/src/routes/mod.rs"),
172        "// === ROMANCE:ROUTES ===",
173        "        .merge(oauth::router())",
174    )?;
175
176    // Register migration
177    let lib_path = project_root.join("backend/migration/src/lib.rs");
178    utils::insert_at_marker(
179        &lib_path,
180        "// === ROMANCE:MIGRATION_MODS ===",
181        &format!("mod {};", migration_module),
182    )?;
183    utils::insert_at_marker(
184        &lib_path,
185        "// === ROMANCE:MIGRATIONS ===",
186        &format!("            Box::new({}::Migration),", migration_module),
187    )?;
188
189    // Inject oauth fields into user entity model (both Model and UserPublic)
190    let user_model_path = project_root.join("backend/src/entities/user.rs");
191    if user_model_path.exists() {
192        let mut user_content = std::fs::read_to_string(&user_model_path)?;
193        if !user_content.contains("oauth_provider") {
194            // Add oauth fields to Model struct: insert before "pub created_at" in "pub struct Model"
195            if let Some(model_pos) = user_content.find("pub struct Model") {
196                if let Some(rel_pos) = user_content[model_pos..].find("    pub created_at:") {
197                    let insert_pos = model_pos + rel_pos;
198                    user_content.insert_str(insert_pos, "    pub oauth_provider: Option<String>,\n    pub oauth_id: Option<String>,\n");
199                }
200            }
201            // Add oauth fields to UserPublic struct: insert before "pub created_at" in "pub struct UserPublic"
202            if let Some(up_pos) = user_content.find("pub struct UserPublic") {
203                if let Some(rel_pos) = user_content[up_pos..].find("    pub created_at:") {
204                    let insert_pos = up_pos + rel_pos;
205                    user_content.insert_str(insert_pos, "    pub oauth_provider: Option<String>,\n    pub oauth_id: Option<String>,\n");
206                }
207            }
208            std::fs::write(&user_model_path, user_content)?;
209            println!("  {} backend/src/entities/user.rs (added oauth fields)", "update".green());
210        }
211    }
212
213    // Patch auth handlers to include oauth fields in UserPublic and ActiveModel
214    let auth_handlers_path = project_root.join("backend/src/handlers/auth.rs");
215    if auth_handlers_path.exists() {
216        let mut auth_content = std::fs::read_to_string(&auth_handlers_path)?;
217        // Add ..Default::default() to ActiveModel in register handler so new optional fields are handled
218        if !auth_content.contains("..Default::default()") {
219            // Find the ActiveModel block and add ..Default::default() before its closing brace
220            // Pattern: "created_at: Set(now),\n    };" in the register function's ActiveModel
221            if let Some(pos) = auth_content.find("created_at: Set(now),\n        updated_at: Set(now),\n    };") {
222                let insert_pos = pos + "created_at: Set(now),\n        updated_at: Set(now),\n".len();
223                auth_content.insert_str(insert_pos, "        ..Default::default()\n");
224            } else if let Some(pos) = auth_content.find("updated_at: Set(now),\n    };") {
225                let insert_pos = pos + "updated_at: Set(now),\n".len();
226                auth_content.insert_str(insert_pos, "        ..Default::default()\n");
227            }
228        }
229        // Add oauth fields to all UserPublic constructions
230        if !auth_content.contains("oauth_provider:") {
231            // Replace "created_at: *.created_at," patterns in UserPublic structs
232            // to include oauth fields before created_at
233            auth_content = auth_content.replace(
234                "        created_at: created.created_at,",
235                "        oauth_provider: created.oauth_provider,\n        oauth_id: created.oauth_id,\n        created_at: created.created_at,",
236            );
237            auth_content = auth_content.replace(
238                "        created_at: user.created_at,",
239                "        oauth_provider: user.oauth_provider.clone(),\n        oauth_id: user.oauth_id.clone(),\n        created_at: user.created_at,",
240            );
241            auth_content = auth_content.replace(
242                "        created_at: updated.created_at,",
243                "        oauth_provider: updated.oauth_provider,\n        oauth_id: updated.oauth_id,\n        created_at: updated.created_at,",
244            );
245            auth_content = auth_content.replace(
246                "            created_at: u.created_at,",
247                "            oauth_provider: u.oauth_provider,\n            oauth_id: u.oauth_id,\n            created_at: u.created_at,",
248            );
249        }
250        std::fs::write(&auth_handlers_path, auth_content)?;
251    }
252
253    // Add mod oauth to main.rs
254    super::add_mod_to_main(project_root, "oauth")?;
255
256    // Add dependencies
257    crate::generator::auth::insert_cargo_dependency(
258        &project_root.join("backend/Cargo.toml"),
259        &[
260            ("oauth2", r#"{ version = "4", features = ["reqwest"] }"#),
261            ("reqwest", r#"{ version = "0.12", features = ["json"] }"#),
262        ],
263    )?;
264
265    // Add env vars
266    let provider_upper = provider.to_uppercase();
267    super::append_env_var(
268        &project_root.join("backend/.env"),
269        &format!("{}_CLIENT_ID=your-client-id", provider_upper),
270    )?;
271    super::append_env_var(
272        &project_root.join("backend/.env"),
273        &format!("{}_CLIENT_SECRET=your-client-secret", provider_upper),
274    )?;
275    super::append_env_var(
276        &project_root.join("backend/.env.example"),
277        &format!("{}_CLIENT_ID=your-client-id", provider_upper),
278    )?;
279    super::append_env_var(
280        &project_root.join("backend/.env.example"),
281        &format!("{}_CLIENT_SECRET=your-client-secret", provider_upper),
282    )?;
283
284    println!();
285    println!(
286        "{}",
287        format!("OAuth ({}) installed successfully!", provider)
288            .green()
289            .bold()
290    );
291    println!(
292        "  Set {}_CLIENT_ID and {}_CLIENT_SECRET in backend/.env",
293        provider_upper, provider_upper
294    );
295
296    Ok(())
297}