1use std::path::PathBuf;
2
3use sofie_model::App;
4
5use super::Edits;
6use crate::gen_command::GenError;
7use crate::source_edits::read;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum AccountScreen {
12 Sessions,
13 TwoFactor,
14 Passkeys,
15 Connections,
16 AccountData,
17}
18
19impl AccountScreen {
20 pub const ALL: [Self; 5] = [
21 Self::Sessions,
22 Self::TwoFactor,
23 Self::Passkeys,
24 Self::Connections,
25 Self::AccountData,
26 ];
27
28 #[must_use]
29 pub fn parse(name: &str) -> Option<Self> {
30 Self::ALL.into_iter().find(|screen| screen.name() == name)
31 }
32
33 fn parts(self) -> (&'static str, &'static str, &'static str) {
36 match self {
37 Self::Sessions => ("sessions", "session-screens", "sessions"),
38 Self::TwoFactor => ("two-factor", "totp", "two_factor"),
39 Self::Passkeys => ("passkeys", "passkeys", "passkeys"),
40 Self::Connections => ("connections", "oauth", "oauth"),
41 Self::AccountData => ("account-data", "account-data", "account_data"),
42 }
43 }
44
45 #[must_use]
46 pub fn name(self) -> &'static str {
47 self.parts().0
48 }
49
50 fn mount(self) -> String {
51 format!("{IDENTITY}::{}::routes", self.parts().2)
52 }
53}
54
55pub(super) fn new_account_screen(
58 edits: &mut Edits,
59 app: &App,
60 area: &str,
61 screen: AccountScreen,
62) -> Result<(), GenError> {
63 let area = defined_area(app, area)?;
64 refuse_mounted(app, &screen.mount())?;
65 edits.enable_feature(&app.app, "sofie-identity", screen.parts().1)?;
66 mount(
67 edits,
68 app,
69 IDENTITY,
70 &[format!("{}::<{area}>", screen.mount())],
71 )
72}
73
74pub(super) fn new_impersonation(
77 edits: &mut Edits,
78 app: &App,
79 permitted: &str,
80 member: &str,
81) -> Result<(), GenError> {
82 let permitted = defined_area(app, permitted)?;
83 let member = defined_area(app, member)?;
84 refuse_mounted(app, IMPERSONATION)?;
85 edits.enable_feature(&app.app, "sofie-identity", "impersonation")?;
86 mount(
87 edits,
88 app,
89 IDENTITY,
90 &[
91 format!("{IMPERSONATION}::routes::<{permitted}, {member}>"),
92 format!("{IMPERSONATION}::stop_routes::<{member}, {permitted}>"),
93 ],
94 )
95}
96
97const IDENTITY: &str = "sofie_identity";
98const IMPERSONATION: &str = "sofie_identity::impersonation";
99
100pub(super) fn new_tenancy(
103 edits: &mut Edits,
104 app: &App,
105 organization: &str,
106 member: &str,
107) -> Result<(), GenError> {
108 let organization = defined_area(app, organization)?;
109 let member = defined_area(app, member)?;
110 refuse_mounted(app, TENANCY)?;
111 edits.depend_on_workspace(&app.app, "sofie-tenancy")?;
112 mount(
113 edits,
114 app,
115 TENANCY,
116 &[
117 format!("{TENANCY}::member_routes::<{member}, {organization}>"),
118 format!("{TENANCY}::organization_routes::<{organization}, {member}>"),
119 ],
120 )
121}
122
123const TENANCY: &str = "sofie_tenancy";
124
125fn app_library(app: &App) -> PathBuf {
126 app.app.path.join("src").join("lib.rs")
127}
128
129fn refuse_mounted(app: &App, mounted: &str) -> Result<(), GenError> {
130 let library = app_library(app);
131 if read(&library)?.contains(mounted) {
132 return Err(GenError::Usage(format!(
133 "{} mounts `{mounted}` already",
134 library.display()
135 )));
136 }
137 Ok(())
138}
139
140fn mount(edits: &mut Edits, app: &App, module: &str, mounts: &[String]) -> Result<(), GenError> {
141 let library = app_library(app);
142 edits.edit_rust(&library, |text| {
143 mounts
144 .iter()
145 .try_fold(text.to_owned(), |text, mounted| {
146 mounted_as(&text, module, mounted)
147 })
148 .ok_or_else(|| {
149 let wanted: Vec<String> = mounts
150 .iter()
151 .map(|mounted| format!("`.area(\"{module}\", {mounted})`"))
152 .collect();
153 GenError::Usage(format!(
154 "{} does not build the app with `wiring::app(db)`; add {} where it does",
155 library.display(),
156 wanted.join(" and ")
157 ))
158 })
159 })
160}
161
162fn defined_area(app: &App, name: &str) -> Result<String, GenError> {
163 app.areas()
164 .into_iter()
165 .find(|known| known.name == name && known.definition.is_some())
166 .map(|known| known.type_path)
167 .ok_or_else(|| GenError::Usage(format!("the app defines no area `{name}`")))
168}
169
170fn mounted_as(text: &str, module: &str, mounted: &str) -> Option<String> {
172 let area = format!("\n .area(\"{module}\", {mounted})");
173 if let Some(at) = text.find(CHAINED) {
174 let end = at + CHAINED.len();
175 return Some(format!("{}{area}{}", &text[..end], &text[end..]));
176 }
177 let at = text.find(WIRED)?;
178 let end = at + WIRED.len();
179 Some(format!(
180 "{}Ok({CHAINED}{area}){}",
181 &text[..at],
182 &text[end..]
183 ))
184}
185
186const WIRED: &str = "wiring::app(db)";
187const CHAINED: &str = "wiring::app(db)?";
188
189#[cfg(test)]
190mod tests {
191 use super::AccountScreen;
192
193 #[test]
194 fn mounts_chain_on_the_wiring_s_app() {
195 let library = "pub async fn app(db: Db) -> Result<App> {\n wiring::app(db)\n}\n";
196
197 let once = super::mounted_as(
198 library,
199 "sofie_identity",
200 "sofie_identity::sessions::routes::<A>",
201 )
202 .unwrap();
203 let twice = super::mounted_as(
204 &once,
205 "sofie_identity",
206 "sofie_identity::passkeys::routes::<A>",
207 )
208 .unwrap();
209
210 assert_eq!(
211 twice,
212 "pub async fn app(db: Db) -> Result<App> {\n Ok(wiring::app(db)?\n \
213 .area(\"sofie_identity\", sofie_identity::passkeys::routes::<A>)\n \
214 .area(\"sofie_identity\", sofie_identity::sessions::routes::<A>))\n}\n"
215 );
216 }
217
218 #[test]
219 fn each_name_reads_back_as_its_screen() {
220 for screen in AccountScreen::ALL {
221 assert_eq!(AccountScreen::parse(screen.name()), Some(screen));
222 }
223 assert_eq!(AccountScreen::parse("profile"), None);
224 }
225}