Skip to main content

doido_auth/generators/
eject.rs

1//! `doido generate auth:controllers` — the `devise:controllers` + `devise:views`
2//! analogue.
3//!
4//! By default, auth works through doido-auth's **built-in** controllers and
5//! views (nothing is copied into the app). This generator *ejects* those into
6//! the project so they can be customized: it writes
7//! `app/controllers/auth/*_controller.rs` + `app/views/auth/*.html.tera`,
8//! registers the `auth` controllers module, and rewires `config/routes.rs` to
9//! point `auth_routes!` at the local controllers.
10//!
11//! Flags:
12//! - `--api` — controllers only, JSON responses (no HTML views).
13//! - `--two-factor` — also eject the 2FA controller/view.
14//! - `--controllers-only` — eject controllers + rewire routes, skip views.
15//! - `--views-only` — eject only the views (built-in controllers keep serving,
16//!   now rendering the app's overriding templates); routes are left unchanged.
17
18use super::route_injector::{
19    read_controllers_mod, read_routes, register_auth_controllers_mod, rewire_local_controllers,
20    CONTROLLERS_MOD_PATH, ROUTES_PATH,
21};
22use super::template;
23use super::{AuthGenerator, GeneratedFile};
24use doido_core::Result;
25
26pub struct AuthControllersGenerator;
27
28fn auth_mod(two_factor: bool) -> String {
29    let oauth_module = "mod oauth_controller;\n";
30    let oauth_use = "pub use oauth_controller::OauthController;\n";
31    let (two_factor_module, two_factor_use) = if two_factor {
32        (
33            "mod two_factor_controller;\n",
34            "pub use two_factor_controller::TwoFactorController;\n",
35        )
36    } else {
37        ("", "")
38    };
39    template("auth/mod.rs.template")
40        .replace("{oauth_module}", oauth_module)
41        .replace("{oauth_use}", oauth_use)
42        .replace("{two_factor_module}", two_factor_module)
43        .replace("{two_factor_use}", two_factor_use)
44}
45
46impl AuthGenerator for AuthControllersGenerator {
47    fn name(&self) -> &str {
48        "auth:controllers"
49    }
50
51    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
52        let api = args.contains(&"--api");
53        let two_factor = args.contains(&"--two-factor");
54        let controllers_only = args.contains(&"--controllers-only");
55        let views_only = args.contains(&"--views-only");
56
57        let emit_controllers = !views_only;
58        // API auth has no HTML views to eject.
59        let emit_views = !controllers_only && !api;
60
61        let suffix = if api { "api" } else { "html" };
62        let mut files = Vec::new();
63
64        if emit_controllers {
65            files.push(GeneratedFile {
66                path: "app/controllers/auth/mod.rs".to_string(),
67                content: auth_mod(two_factor),
68            });
69            files.push(GeneratedFile {
70                path: "app/controllers/auth/sessions_controller.rs".to_string(),
71                content: template(&format!("auth/sessions_controller_{suffix}.rs.template"))
72                    .to_string(),
73            });
74            files.push(GeneratedFile {
75                path: "app/controllers/auth/registrations_controller.rs".to_string(),
76                content: template(&format!(
77                    "auth/registrations_controller_{suffix}.rs.template"
78                ))
79                .to_string(),
80            });
81            files.push(GeneratedFile {
82                path: "app/controllers/auth/passwords_controller.rs".to_string(),
83                content: template(&format!("auth/passwords_controller_{suffix}.rs.template"))
84                    .to_string(),
85            });
86            files.push(GeneratedFile {
87                path: "app/controllers/auth/oauth_controller.rs".to_string(),
88                content: template("auth/oauth_controller.rs.template").to_string(),
89            });
90            if two_factor {
91                files.push(GeneratedFile {
92                    path: "app/controllers/auth/two_factor_controller.rs".to_string(),
93                    content: template(&format!("auth/two_factor_controller_{suffix}.rs.template"))
94                        .to_string(),
95                });
96            }
97            files.push(GeneratedFile {
98                path: CONTROLLERS_MOD_PATH.to_string(),
99                content: register_auth_controllers_mod(&read_controllers_mod()),
100            });
101            files.push(GeneratedFile {
102                path: ROUTES_PATH.to_string(),
103                content: rewire_local_controllers(&read_routes(), two_factor),
104            });
105        }
106
107        if emit_views {
108            for (file, rel) in [
109                ("sign_in", "auth/views/sign_in.html.tera"),
110                ("sign_up", "auth/views/sign_up.html.tera"),
111                ("password_new", "auth/views/password_new.html.tera"),
112                ("password_edit", "auth/views/password_edit.html.tera"),
113            ] {
114                files.push(GeneratedFile {
115                    path: format!("app/views/auth/{file}.html.tera"),
116                    content: template(rel).to_string(),
117                });
118            }
119            if two_factor {
120                files.push(GeneratedFile {
121                    path: "app/views/auth/two_factor.html.tera".to_string(),
122                    content: template("auth/views/two_factor.html.tera").to_string(),
123                });
124            }
125        }
126
127        Ok(files)
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn ejects_controllers_views_and_rewires_routes() {
137        let files = AuthControllersGenerator.generate(&[]).unwrap();
138        for path in [
139            "app/controllers/auth/mod.rs",
140            "app/controllers/auth/sessions_controller.rs",
141            "app/controllers/auth/registrations_controller.rs",
142            "app/controllers/auth/passwords_controller.rs",
143            "app/controllers/auth/oauth_controller.rs",
144            "app/views/auth/sign_in.html.tera",
145            "app/views/auth/sign_up.html.tera",
146        ] {
147            assert!(
148                files.iter().any(|f| f.path == path),
149                "expected ejected file {path}"
150            );
151        }
152        let controllers_mod = files
153            .iter()
154            .find(|f| f.path == CONTROLLERS_MOD_PATH)
155            .unwrap();
156        assert!(controllers_mod.content.contains("pub mod auth;"));
157    }
158
159    #[test]
160    fn rewires_installed_bare_route_to_local_controllers() {
161        // Simulate an app that already ran `auth:install`: bare route + User import.
162        std::fs::create_dir_all("config").ok();
163        std::fs::write(
164            ROUTES_PATH,
165            "use doido::controller::axum;\nuse crate::models::user::Model as User;\n\npub fn router() -> axum::Router {\n    doido::auth::routes! {\n        auth_routes!(User);\n    }\n}\n",
166        )
167        .unwrap();
168
169        let files = AuthControllersGenerator.generate(&[]).unwrap();
170        let routes = files.iter().find(|f| f.path == ROUTES_PATH).unwrap();
171        assert!(routes.content.contains("controllers: {"));
172        assert!(routes
173            .content
174            .contains("sessions: auth::SessionsController"));
175        assert!(routes.content.contains("use crate::controllers::auth;"));
176        assert!(!routes.content.contains("auth_routes!(User);"));
177        // The `User` import stays: non-overridden modules (e.g. confirmation)
178        // still expand to built-in `AuthXxx::<User>` handlers.
179        assert!(routes
180            .content
181            .contains("use crate::models::user::Model as User;"));
182
183        let _ = std::fs::remove_file(ROUTES_PATH);
184    }
185
186    #[test]
187    fn api_flag_skips_views() {
188        let files = AuthControllersGenerator.generate(&["--api"]).unwrap();
189        assert!(!files.iter().any(|f| f.path.starts_with("app/views/auth/")));
190        assert!(files
191            .iter()
192            .any(|f| f.path == "app/controllers/auth/sessions_controller.rs"));
193    }
194
195    #[test]
196    fn views_only_skips_controllers_and_routes() {
197        let files = AuthControllersGenerator
198            .generate(&["--views-only"])
199            .unwrap();
200        assert!(!files
201            .iter()
202            .any(|f| f.path.starts_with("app/controllers/auth/")));
203        assert!(!files.iter().any(|f| f.path == ROUTES_PATH));
204        assert!(files
205            .iter()
206            .any(|f| f.path == "app/views/auth/sign_in.html.tera"));
207    }
208}