1use super::migration_support::{
8 register_migration, render_migration_file, MIGRATION_LIB_BASE, MIGRATION_SRC_DIR,
9};
10use super::route_injector::{
11 inject_auth_routes, read_controllers_mod, read_models_mod, read_routes,
12 register_auth_controllers_mod, register_model_module, CONTROLLERS_MOD_PATH, MODELS_MOD_PATH,
13 ROUTES_PATH,
14};
15use super::template;
16use super::{AuthGenerator, GeneratedFile};
17use chrono::Utc;
18use doido_core::Result;
19
20pub struct AuthInstallGenerator;
21
22const IMPORTS: &str = "use doido::model::migration::{create_table, drop_table};";
23
24fn users_up_body(two_factor: bool) -> String {
25 let mut body = String::from(
26 " create_table(manager, \"users\", |t| {\n\
27 \x20 t.string(\"email\").not_null().unique_key();\n\
28 \x20 t.string(\"password_digest\").not_null();\n",
29 );
30 if two_factor {
31 body.push_str(" t.string(\"two_factor_secret\");\n");
32 body.push_str(" t.boolean(\"two_factor_enabled\").not_null();\n");
33 }
34 body.push_str(
35 " t.timestamp(\"created_at\").not_null();\n\
36 \x20 t.timestamp(\"updated_at\").not_null();\n\
37 \x20 })\n\
38 \x20 .await\n",
39 );
40 body
41}
42
43const DOWN_BODY: &str = " drop_table(manager, \"users\").await\n";
44
45fn auth_section(two_factor: bool) -> String {
46 let enabled = if two_factor { "true" } else { "false" };
47 format!(
48 "\nauth:\n user_model: User\n strategies:\n - cookie\n two_factor:\n enabled: {enabled}\n issuer: MyApp\n routes:\n prefix: /users\n"
49 )
50}
51
52fn config_file(path: &str, two_factor: bool) -> Option<GeneratedFile> {
53 let existing = std::fs::read_to_string(path).ok()?;
54 if existing.contains("\nauth:") || existing.starts_with("auth:") {
55 return None;
56 }
57 Some(GeneratedFile {
58 path: path.to_string(),
59 content: format!("{}{}", existing.trim_end(), auth_section(two_factor)),
60 })
61}
62
63fn user_model(two_factor: bool) -> String {
64 let two_factor_fields = if two_factor {
65 " pub two_factor_secret: Option<String>,\n pub two_factor_enabled: bool,\n"
66 } else {
67 ""
68 };
69 template("user.rs.template").replace("{two_factor_fields}", two_factor_fields)
70}
71
72fn auth_mod(two_factor: bool) -> String {
73 let oauth_module = "mod oauth_controller;\n";
74 let oauth_use = "pub use oauth_controller::OauthController;\n";
75 let (two_factor_module, two_factor_use) = if two_factor {
76 (
77 "mod two_factor_controller;\n",
78 "pub use two_factor_controller::TwoFactorController;\n",
79 )
80 } else {
81 ("", "")
82 };
83 template("auth/mod.rs.template")
84 .replace("{oauth_module}", oauth_module)
85 .replace("{oauth_use}", oauth_use)
86 .replace("{two_factor_module}", two_factor_module)
87 .replace("{two_factor_use}", two_factor_use)
88}
89
90impl AuthGenerator for AuthInstallGenerator {
91 fn name(&self) -> &str {
92 "auth:install"
93 }
94
95 fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
96 let api = args.contains(&"--api");
97 let two_factor = args.contains(&"--two-factor");
98
99 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
100 let migration_module = format!("m{timestamp}_create_users_table");
101 let migration = render_migration_file(
102 &migration_module,
103 IMPORTS,
104 &users_up_body(two_factor),
105 DOWN_BODY,
106 );
107
108 let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
109 let existing =
110 std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
111 let lib = register_migration(&existing, &migration_module);
112
113 let models_mod = register_model_module(&read_models_mod(), "user");
114 let controllers_mod = register_auth_controllers_mod(&read_controllers_mod());
115 let routes = inject_auth_routes(&read_routes(), api);
116
117 let suffix = if api { "api" } else { "html" };
118
119 let mut files = vec![
120 GeneratedFile {
121 path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
122 content: migration,
123 },
124 GeneratedFile {
125 path: lib_path,
126 content: lib,
127 },
128 GeneratedFile {
129 path: "app/models/user.rs".to_string(),
130 content: user_model(two_factor),
131 },
132 GeneratedFile {
133 path: MODELS_MOD_PATH.to_string(),
134 content: models_mod,
135 },
136 GeneratedFile {
137 path: "app/controllers/auth/mod.rs".to_string(),
138 content: auth_mod(two_factor),
139 },
140 GeneratedFile {
141 path: "app/controllers/auth/sessions_controller.rs".to_string(),
142 content: template(&format!("auth/sessions_controller_{suffix}.rs.template"))
143 .to_string(),
144 },
145 GeneratedFile {
146 path: "app/controllers/auth/registrations_controller.rs".to_string(),
147 content: template(&format!(
148 "auth/registrations_controller_{suffix}.rs.template"
149 ))
150 .to_string(),
151 },
152 GeneratedFile {
153 path: "app/controllers/auth/passwords_controller.rs".to_string(),
154 content: template(&format!("auth/passwords_controller_{suffix}.rs.template"))
155 .to_string(),
156 },
157 GeneratedFile {
158 path: "app/controllers/auth/oauth_controller.rs".to_string(),
159 content: template("auth/oauth_controller.rs.template").to_string(),
160 },
161 GeneratedFile {
162 path: CONTROLLERS_MOD_PATH.to_string(),
163 content: controllers_mod,
164 },
165 GeneratedFile {
166 path: ROUTES_PATH.to_string(),
167 content: routes,
168 },
169 ];
170
171 if two_factor {
172 files.push(GeneratedFile {
173 path: "app/controllers/auth/two_factor_controller.rs".to_string(),
174 content: template(&format!("auth/two_factor_controller_{suffix}.rs.template"))
175 .to_string(),
176 });
177 }
178
179 if !api {
180 for (file, rel) in [
181 ("sign_in", "auth/views/sign_in.html.tera"),
182 ("sign_up", "auth/views/sign_up.html.tera"),
183 ("password_new", "auth/views/password_new.html.tera"),
184 ("password_edit", "auth/views/password_edit.html.tera"),
185 ] {
186 files.push(GeneratedFile {
187 path: format!("app/views/auth/{file}.html.tera"),
188 content: template(rel).to_string(),
189 });
190 }
191 if two_factor {
192 files.push(GeneratedFile {
193 path: "app/views/auth/two_factor.html.tera".to_string(),
194 content: template("auth/views/two_factor.html.tera").to_string(),
195 });
196 }
197 }
198
199 if let Some(f) = config_file("config/development.yml", two_factor) {
200 files.push(f);
201 }
202 if let Some(f) = config_file("config/test.yml", two_factor) {
203 files.push(f);
204 }
205
206 Ok(files)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn emits_users_migration_and_routes() {
216 let files = AuthInstallGenerator.generate(&[]).unwrap();
217 let migration = files
218 .iter()
219 .find(|f| f.path.contains("create_users_table"))
220 .expect("users migration");
221 assert!(migration.content.contains("password_digest"));
222 assert!(migration
223 .content
224 .contains("impl MigrationName for Migration"));
225
226 let routes = files
227 .iter()
228 .find(|f| f.path == ROUTES_PATH)
229 .expect("routes.rs");
230 assert!(routes.content.contains("SessionsController::create"));
231 assert!(routes.content.contains("use crate::controllers::auth;"));
232
233 let user = files
234 .iter()
235 .find(|f| f.path == "app/models/user.rs")
236 .expect("user model");
237 assert!(user.content.contains("impl AuthUser for Model"));
238 }
239
240 #[test]
241 fn two_factor_adds_columns_and_controller() {
242 let files = AuthInstallGenerator.generate(&["--two-factor"]).unwrap();
243 let migration = files
244 .iter()
245 .find(|f| f.path.contains("create_users_table"))
246 .unwrap();
247 assert!(migration.content.contains("two_factor_secret"));
248 assert!(files
249 .iter()
250 .any(|f| f.path.ends_with("two_factor_controller.rs")));
251 }
252
253 #[test]
254 fn api_mode_skips_html_views() {
255 let files = AuthInstallGenerator.generate(&["--api"]).unwrap();
256 assert!(!files.iter().any(|f| f.path.contains("app/views/auth/")));
257 let sessions = files
258 .iter()
259 .find(|f| f.path.ends_with("sessions_controller.rs"))
260 .unwrap();
261 assert!(sessions.content.contains("body_json"));
262 }
263}