steid

@jamesgill /

1//! First-run claim and sign-in.
2
3use serde::Deserialize;
4use topcoat::{
5 Result,
6 context::Cx,
7 router::{
8 content::Form,
9 error::{SeeOther, redirect, see_other},
10 page, route,
11 },
12 session,
13 view::view,
14};
15
16use crate::{
17 application::{OwnerSpec, claim_instance, end_session, login, record_session},
18 domain::Actor,
19 infrastructure::{
20 password::Argon2Hasher,
21 repository::{SqliteMembershipRepo, SqliteOrgRepo, SqliteSessionRepo, SqliteUserRepo},
22 },
23};
24
25use super::context::{claimed, hex, pool, setup_token};
26
27#[derive(Debug, Deserialize)]
28struct ClaimForm {
29 token: String,
30 handle: String,
31 email: String,
32 password: String,
33}
34
35#[derive(Debug, Deserialize)]
36struct LoginForm {
37 email: String,
38 password: String,
39}
40
41#[page("/setup")]
42async fn setup_page(cx: &Cx) -> Result {
43 if claimed(cx).await? {
44 return Err(redirect("/").into());
45 }
46
47 view! {
48 <h1>"Claim this instance"</h1>
49 <p>"The setup token was printed to the server log at startup."</p>
50 <form method="post" action="/setup">
51 <p><label>"Setup token " <input type="text" name="token" required="true" /></label></p>
52 <p><label>"Handle " <input type="text" name="handle" required="true" /></label></p>
53 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
54 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
55 <button type="submit">"Claim"</button>
56 </form>
57 }
58}
59
60#[route(POST "/setup")]
61async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
62 // Absent once claimed, so a claimed instance cannot be re-claimed even if the
63 // use case were somehow reached.
64 let token = setup_token(cx).ok_or_else(|| redirect("/"))?;
65
66 let pool = pool(cx).clone();
67 let users = SqliteUserRepo::new(pool.clone());
68 let orgs = SqliteOrgRepo::new(pool.clone());
69 let memberships = SqliteMembershipRepo::new(pool.clone());
70 let sessions = SqliteSessionRepo::new(pool);
71
72 let actor = claim_instance(
73 &form.token,
74 token,
75 &OwnerSpec {
76 handle: form.handle,
77 email: form.email,
78 password: form.password,
79 },
80 &users,
81 &orgs,
82 &memberships,
83 &Argon2Hasher::new(),
84 )
85 .await
86 .map_err(|error| {
87 eprintln!("steid: claim rejected: {error}");
88 redirect("/setup")
89 })?;
90
91 sign_in(cx, &actor, &sessions).await?;
92
93 Ok(see_other("/"))
94}
95
96#[page("/login")]
97async fn login_page(cx: &Cx) -> Result {
98 if !claimed(cx).await? {
99 return Err(redirect("/setup").into());
100 }
101
102 view! {
103 <h1>"Sign in"</h1>
104 <form method="post" action="/login">
105 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
106 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
107 <button type="submit">"Sign in"</button>
108 </form>
109 }
110}
111
112#[route(POST "/login")]
113async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther> {
114 let pool = pool(cx).clone();
115 let users = SqliteUserRepo::new(pool.clone());
116 let sessions = SqliteSessionRepo::new(pool);
117
118 let actor = login(&form.email, &form.password, &users, &Argon2Hasher::new())
119 .await
120 .map_err(|error| {
121 eprintln!("steid: login rejected: {error}");
122 redirect("/login")
123 })?;
124
125 sign_in(cx, &actor, &sessions).await?;
126
127 Ok(see_other("/"))
128}
129
130#[route(POST "/logout")]
131async fn logout(cx: &Cx) -> Result<SeeOther> {
132 // Both halves: the client discards its token, and the record goes. Doing only the
133 // first leaves the session valid server-side.
134 if let Some(hash) = session::stop(cx).await? {
135 let sessions = SqliteSessionRepo::new(pool(cx).clone());
136 end_session(
137 &crate::domain::SessionTokenHash::from_trusted(hex(&hash)),
138 &sessions,
139 )
140 .await
141 .map_err(topcoat::Error::from)?;
142 }
143
144 Ok(see_other("/"))
145}
146
147/// Issues a session for an authenticated actor and records it.
148async fn sign_in(cx: &Cx, actor: &Actor, sessions: &SqliteSessionRepo) -> Result<()> {
149 let Some(user_id) = actor.user_id() else {
150 return Ok(());
151 };
152
153 let session = session::start(cx).await?;
154
155 record_session(
156 crate::domain::SessionTokenHash::from_trusted(hex(&session.token_hash)),
157 user_id.clone(),
158 session.expires_at,
159 sessions,
160 )
161 .await
162 .map_err(topcoat::Error::from)?;
163
164 Ok(())
165}