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;
65 template("user.rs.template").to_string()
66}
67
68fn user_entity(two_factor: bool) -> String {
69 let two_factor_fields = if two_factor {
70 " pub two_factor_secret: Option<String>,\n pub two_factor_enabled: bool,\n"
71 } else {
72 ""
73 };
74 template("user_entity.rs.template").replace("{two_factor_fields}", two_factor_fields)
75}
76
77fn entities_mod(existing: &str) -> String {
78 doido_model::entities::register_entity_module(existing, "users")
79}
80
81fn auth_mod(two_factor: bool) -> String {
82 let oauth_module = "mod oauth_controller;\n";
83 let oauth_use = "pub use oauth_controller::OauthController;\n";
84 let (two_factor_module, two_factor_use) = if two_factor {
85 (
86 "mod two_factor_controller;\n",
87 "pub use two_factor_controller::TwoFactorController;\n",
88 )
89 } else {
90 ("", "")
91 };
92 template("auth/mod.rs.template")
93 .replace("{oauth_module}", oauth_module)
94 .replace("{oauth_use}", oauth_use)
95 .replace("{two_factor_module}", two_factor_module)
96 .replace("{two_factor_use}", two_factor_use)
97}
98
99impl AuthGenerator for AuthInstallGenerator {
100 fn name(&self) -> &str {
101 "auth:install"
102 }
103
104 fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
105 let api = args.contains(&"--api");
106 let two_factor = args.contains(&"--two-factor");
107
108 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
109 let migration_module = format!("m{timestamp}_create_users_table");
110 let migration = render_migration_file(
111 &migration_module,
112 IMPORTS,
113 &users_up_body(two_factor),
114 DOWN_BODY,
115 );
116
117 let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
118 let existing =
119 std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
120 let lib = register_migration(&existing, &migration_module);
121
122 let models_mod = register_model_module(&read_models_mod(), "user");
123 let entities_mod_path = "app/models/_entities/mod.rs";
124 let entities_mod_base = std::fs::read_to_string(entities_mod_path).unwrap_or_else(|_| {
125 include_str!("../../templates/new/app/models/_entities/mod.rs").to_string()
126 });
127 let entities_mod = entities_mod(&entities_mod_base);
128 let controllers_mod = register_auth_controllers_mod(&read_controllers_mod());
129 let routes = inject_auth_routes(&read_routes(), api);
130
131 let suffix = if api { "api" } else { "html" };
132
133 let mut files = vec![
134 GeneratedFile {
135 path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
136 content: migration,
137 },
138 GeneratedFile {
139 path: lib_path,
140 content: lib,
141 },
142 GeneratedFile {
143 path: "app/models/_entities/users.rs".to_string(),
144 content: user_entity(two_factor),
145 },
146 GeneratedFile {
147 path: entities_mod_path.to_string(),
148 content: entities_mod,
149 },
150 GeneratedFile {
151 path: "app/models/user.rs".to_string(),
152 content: user_model(two_factor),
153 },
154 GeneratedFile {
155 path: MODELS_MOD_PATH.to_string(),
156 content: models_mod,
157 },
158 GeneratedFile {
159 path: "app/controllers/auth/mod.rs".to_string(),
160 content: auth_mod(two_factor),
161 },
162 GeneratedFile {
163 path: "app/controllers/auth/sessions_controller.rs".to_string(),
164 content: template(&format!("auth/sessions_controller_{suffix}.rs.template"))
165 .to_string(),
166 },
167 GeneratedFile {
168 path: "app/controllers/auth/registrations_controller.rs".to_string(),
169 content: template(&format!(
170 "auth/registrations_controller_{suffix}.rs.template"
171 ))
172 .to_string(),
173 },
174 GeneratedFile {
175 path: "app/controllers/auth/passwords_controller.rs".to_string(),
176 content: template(&format!("auth/passwords_controller_{suffix}.rs.template"))
177 .to_string(),
178 },
179 GeneratedFile {
180 path: "app/controllers/auth/oauth_controller.rs".to_string(),
181 content: template("auth/oauth_controller.rs.template").to_string(),
182 },
183 GeneratedFile {
184 path: CONTROLLERS_MOD_PATH.to_string(),
185 content: controllers_mod,
186 },
187 GeneratedFile {
188 path: ROUTES_PATH.to_string(),
189 content: routes,
190 },
191 ];
192
193 if two_factor {
194 files.push(GeneratedFile {
195 path: "app/controllers/auth/two_factor_controller.rs".to_string(),
196 content: template(&format!("auth/two_factor_controller_{suffix}.rs.template"))
197 .to_string(),
198 });
199 }
200
201 if !api {
202 for (file, rel) in [
203 ("sign_in", "auth/views/sign_in.html.tera"),
204 ("sign_up", "auth/views/sign_up.html.tera"),
205 ("password_new", "auth/views/password_new.html.tera"),
206 ("password_edit", "auth/views/password_edit.html.tera"),
207 ] {
208 files.push(GeneratedFile {
209 path: format!("app/views/auth/{file}.html.tera"),
210 content: template(rel).to_string(),
211 });
212 }
213 if two_factor {
214 files.push(GeneratedFile {
215 path: "app/views/auth/two_factor.html.tera".to_string(),
216 content: template("auth/views/two_factor.html.tera").to_string(),
217 });
218 }
219 }
220
221 if let Some(f) = config_file("config/development.yml", two_factor) {
222 files.push(f);
223 }
224 if let Some(f) = config_file("config/test.yml", two_factor) {
225 files.push(f);
226 }
227
228 Ok(files)
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn emits_users_migration_and_routes() {
238 let files = AuthInstallGenerator.generate(&[]).unwrap();
239 let migration = files
240 .iter()
241 .find(|f| f.path.contains("create_users_table"))
242 .expect("users migration");
243 assert!(migration.content.contains("password_digest"));
244 assert!(migration
245 .content
246 .contains("impl MigrationName for Migration"));
247
248 let routes = files
249 .iter()
250 .find(|f| f.path == ROUTES_PATH)
251 .expect("routes.rs");
252 assert!(routes.content.contains("auth_routes!(User"));
253 assert!(routes
254 .content
255 .contains("sessions: auth::SessionsController"));
256 assert!(routes.content.contains("use crate::controllers::auth;"));
257
258 let user = files
259 .iter()
260 .find(|f| f.path == "app/models/user.rs")
261 .expect("user model");
262 assert!(user.content.contains("impl AuthUser for Model"));
263 }
264
265 #[test]
266 fn two_factor_adds_columns_and_controller() {
267 let files = AuthInstallGenerator.generate(&["--two-factor"]).unwrap();
268 let migration = files
269 .iter()
270 .find(|f| f.path.contains("create_users_table"))
271 .unwrap();
272 assert!(migration.content.contains("two_factor_secret"));
273 assert!(files
274 .iter()
275 .any(|f| f.path.ends_with("two_factor_controller.rs")));
276 }
277
278 #[test]
279 fn api_mode_skips_html_views() {
280 let files = AuthInstallGenerator.generate(&["--api"]).unwrap();
281 assert!(!files.iter().any(|f| f.path.contains("app/views/auth/")));
282 let sessions = files
283 .iter()
284 .find(|f| f.path.ends_with("sessions_controller.rs"))
285 .unwrap();
286 assert!(sessions.content.contains("body_json"));
287 }
288}