Skip to main content

sofie_cli/new_command/
account_screen.rs

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/// A member's own screen sofie-identity ships, mounted in one of the module's areas.
10#[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    /// Its name on the command line, the sofie-identity feature that brings it, and the
34    /// sofie-identity module mounting it.
35    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
55/// The app's `app` mounts it for the area, as `sofie routes` reads it, and the app's
56/// sofie-identity dependency gets its feature.
57pub(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
74/// Signing in as a member of `member` for a permitted principal of `permitted`, each area
75/// mounting its half.
76pub(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
100/// Organizations with their members, where `organization` acts for one and `member` holds
101/// an account's list of them; both mounted, and the app depending on sofie-tenancy.
102pub(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
170/// The wiring's app gets one `.area(…)` more, the first turning it into a chain.
171fn 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}