Functional login and fetching of installations

This commit is contained in:
Mikko Ahlroth 2023-09-16 22:28:25 +03:00
parent d21a5fb99f
commit 28c09b5086
17 changed files with 471 additions and 41 deletions

2
.gitignore vendored
View file

@ -7,3 +7,5 @@ src/geo_therminator.gleam
/old /old
/priv/config.mjs /priv/config.mjs
/chrome-data-dir

View file

@ -9,3 +9,4 @@ gleam_json = "~> 0.6.0"
gleam_javascript = "~> 0.6.0" gleam_javascript = "~> 0.6.0"
gleam_fetch = "~> 0.2.0" gleam_fetch = "~> 0.2.0"
lustre = "~> 3.0" lustre = "~> 3.0"
varasto = "~> 1.0"

View file

@ -2,13 +2,15 @@
# You typically do not need to edit this file # You typically do not need to edit this file
packages = [ packages = [
{ name = "gleam_fetch", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_http"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "D0C9E9CAE8D6EFCCC3A9FF817DCA9ED327097222086D91DE4F6CA8FBAB02D79F" }, { name = "gleam_fetch", version = "0.2.0", build_tools = ["gleam"], requirements = ["gleam_http", "gleam_javascript"], otp_app = "gleam_fetch", source = "hex", outer_checksum = "D0C9E9CAE8D6EFCCC3A9FF817DCA9ED327097222086D91DE4F6CA8FBAB02D79F" },
{ name = "gleam_http", version = "3.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FAE9AE3EB1CA90C2194615D20FFFD1E28B630E84DACA670B28D959B37BCBB02C" }, { name = "gleam_http", version = "3.5.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "FAE9AE3EB1CA90C2194615D20FFFD1E28B630E84DACA670B28D959B37BCBB02C" },
{ name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" }, { name = "gleam_javascript", version = "0.6.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "BFEBB63ABE4A1694E07DEFD19B160C2980304B5D775A89D4B02E7DE7C9D8008B" },
{ name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" }, { name = "gleam_json", version = "0.6.0", build_tools = ["gleam"], requirements = ["thoas", "gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "C6CC5BEECA525117E97D0905013AB3F8836537455645DDDD10FE31A511B195EF" },
{ name = "gleam_stdlib", version = "0.30.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "8D8BF3790AA31176B1E1C0B517DD74C86DA8235CF3389EA02043EE4FD82AE3DC" }, { name = "gleam_stdlib", version = "0.30.2", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "8D8BF3790AA31176B1E1C0B517DD74C86DA8235CF3389EA02043EE4FD82AE3DC" },
{ name = "lustre", version = "3.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "5D4938048F24F0F04EFCBF1ED62485228EBF4317F4235DB66DDBA6E1B33BAC50" }, { name = "lustre", version = "3.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "lustre", source = "hex", outer_checksum = "9A18A7588776C14FD14D3838DF5881EF12C611C2F55637DA528A60BCCA135B41" },
{ name = "plinth", version = "0.1.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "plinth", source = "hex", outer_checksum = "E40A48FAA3AB9410803AB937BE620692D86B7ABB46459A83E8C674B82CFFD05B" },
{ name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" }, { name = "thoas", version = "0.4.1", build_tools = ["rebar3"], requirements = [], otp_app = "thoas", source = "hex", outer_checksum = "4918D50026C073C4AB1388437132C77A6F6F7C8AC43C60C13758CC0ADCE2134E" },
{ name = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
] ]
[requirements] [requirements]
@ -18,3 +20,4 @@ gleam_javascript = { version = "~> 0.6.0" }
gleam_json = { version = "~> 0.6.0" } gleam_json = { version = "~> 0.6.0" }
gleam_stdlib = { version = "~> 0.30" } gleam_stdlib = { version = "~> 0.30" }
lustre = { version = "~> 3.0" } lustre = { version = "~> 3.0" }
varasto = { version = "~> 1.0" }

View file

@ -7,6 +7,7 @@
<title>GeoTherminator</title> <title>GeoTherminator</title>
<script type="module"> <script type="module">
import "./build/dev/javascript/geo_therminator/priv/config.mjs";
import { main } from "./build/dev/javascript/geo_therminator/geo_t/web.mjs"; import { main } from "./build/dev/javascript/geo_therminator/geo_t/web.mjs";
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {

View file

@ -1,6 +1,7 @@
import { Ok, Error, BitString } from "./gleam.mjs"; import { Ok, Error, BitString } from "./gleam.mjs";
let crypto; let crypto;
const encoder = new TextEncoder();
export async function hash(algo, data) { export async function hash(algo, data) {
if (!crypto) { if (!crypto) {
@ -11,6 +12,8 @@ export async function hash(algo, data) {
} }
} }
data = encoder.encode(data);
try { try {
const hash = await crypto.subtle.digest(algo, data); const hash = await crypto.subtle.digest(algo, data);
const hashArray = new Uint8Array(hash); const hashArray = new Uint8Array(hash);

View file

@ -12,3 +12,23 @@ function construct(input) {
export const from_iso8601 = construct; export const from_iso8601 = construct;
export const from_unix = construct; export const from_unix = construct;
export function to_iso8601(date) {
return date.toISOString();
}
export function unix_now() {
return Date.now();
}
export function now() {
return new Date();
}
export function equals(a, b) {
return a.getTime() === b.getTime();
}
export function bigger_than(a, b) {
return a > b;
}

View file

@ -3,3 +3,7 @@ export function preventRedirection(request) {
redirect: "manual", redirect: "manual",
}); });
} }
export function url(resp) {
return resp.url;
}

View file

@ -15,6 +15,7 @@ import gleam/list
import gleam/result import gleam/result
import gleam/int import gleam/int
import gleam/fetch import gleam/fetch
import gleam/bool
import gleam/javascript/promise.{Promise} import gleam/javascript/promise.{Promise}
import geo_t/azure/utils import geo_t/azure/utils
import geo_t/helpers/crypto import geo_t/helpers/crypto
@ -178,14 +179,7 @@ fn confirm(
self_asserted: SelfAsserted, self_asserted: SelfAsserted,
auth_info: AuthInfo, auth_info: AuthInfo,
) -> Promise(Result(Confirmed, B2CError)) { ) -> Promise(Result(Confirmed, B2CError)) {
let csrf_cookie_key = "x-ms-cpim-csrf" let cookies = self_asserted.cookies
use csrf_cookie <- promise.try_await(promise.resolve(
list.key_find(auth_info.cookies, csrf_cookie_key)
|> b2c_error("CSRF cookie not found in auth info."),
))
let cookies = [#(csrf_cookie_key, csrf_cookie), ..self_asserted.cookies]
let req = build_req(confirm_url(config), http.Get) let req = build_req(confirm_url(config), http.Get)
let req = let req =
@ -199,26 +193,23 @@ fn confirm(
..base_query(auth_info) ..base_query(auth_info)
]) ])
use resp <- promise.try_await( use raw_resp <- promise.try_await(
req req
|> fetch.to_fetch_request() |> fetch.to_fetch_request()
|> fetch_helpers.prevent_redirection()
|> fetch_helpers.log_raw_send() |> fetch_helpers.log_raw_send()
|> request_error(), |> request_error(),
) )
use resp <- promise.try_await(case resp.status { let resp = fetch.from_fetch_response(raw_resp)
302 -> promise.resolve(Ok(resp))
error ->
promise.resolve(Error(ContentError(
msg: "Confirm HTTP request bad error code: " <> int.to_string(error),
)))
})
use location <- promise.try_await(promise.resolve( use <- bool.guard(
response.get_header(resp, "location") when: resp.status != 200,
|> b2c_error("Location not found for confirm response."), return: promise.resolve(Error(ContentError(
)) msg: "Confirm HTTP request bad error code: " <> int.to_string(resp.status),
))),
)
let location = fetch_helpers.url(raw_resp)
use code <- promise.try_await(promise.resolve( use code <- promise.try_await(promise.resolve(
location location

View file

@ -26,6 +26,7 @@ pub type Config {
b2c_auth_url: Uri, b2c_auth_url: Uri,
api_opstat_mapping: Map(Int, OpStat), api_opstat_mapping: Map(Int, OpStat),
api_opstat_bitmask_mapping: Map(Int, OpStat), api_opstat_bitmask_mapping: Map(Int, OpStat),
local_storage_prefix: String,
) )
} }
@ -104,6 +105,10 @@ pub fn load_config() -> Config {
config_helpers.b2c_auth_url, config_helpers.b2c_auth_url,
"https://thermialogin.b2clogin.com/thermialogin.onmicrosoft.com/b2c_1a_signuporsigninonline", "https://thermialogin.b2clogin.com/thermialogin.onmicrosoft.com/b2c_1a_signuporsigninonline",
), ),
local_storage_prefix: get_env(
config_helpers.local_storage_prefix,
"__geo_therminator__",
),
) )
} }

View file

@ -56,6 +56,10 @@ pub fn api_device_reg_set_client_id() {
config("api_device_reg_set_client_id", dynamic.string) config("api_device_reg_set_client_id", dynamic.string)
} }
pub fn local_storage_prefix() {
config("local_storage_prefix", dynamic.string)
}
fn config(config_key: String, decoder: dynamic.Decoder(a)) { fn config(config_key: String, decoder: dynamic.Decoder(a)) {
config_key config_key
|> get_conf() |> get_conf()

View file

@ -1,7 +1,48 @@
import gleam/result
import gleam/order.{Eq, Gt, Lt, Order}
import gleam/dynamic.{DecodeError, Dynamic}
pub type Date pub type Date
@external(javascript, "../../date_ffi.mjs", "from_iso8601") @external(javascript, "../../date_ffi.mjs", "from_iso8601")
pub fn from_iso8601(a: String) -> Result(Date, Nil) pub fn from_iso8601(a: String) -> Result(Date, Nil)
@external(javascript, "../../date_ffi.mjs", "to_iso8601")
pub fn to_iso8601(d: Date) -> String
@external(javascript, "../../date_ffi.mjs", "from_unix") @external(javascript, "../../date_ffi.mjs", "from_unix")
pub fn from_unix(a: Int) -> Result(Date, Nil) pub fn from_unix(a: Int) -> Result(Date, Nil)
@external(javascript, "../../date_ffi.mjs", "now")
pub fn now() -> Date
@external(javascript, "../../date_ffi.mjs", "unix_now")
pub fn unix_now() -> Int
@external(javascript, "../../date_ffi.mjs", "equals")
pub fn equals(a: Date, b: Date) -> Bool
@external(javascript, "../../date_ffi.mjs", "bigger_than")
pub fn bigger_than(a: Date, b: Date) -> Bool
/// Compare given dates, returning `Gt` if `a` > `b`
pub fn compare(a: Date, b: Date) -> Order {
case equals(a, b) {
True -> Eq
False -> {
case bigger_than(a, b) {
True -> Gt
False -> Lt
}
}
}
}
pub fn decode(value: Dynamic) -> Result(Date, List(DecodeError)) {
use str <- result.try(dynamic.string(value))
use date <- result.try(
from_iso8601(str)
|> result.replace_error([DecodeError("ISO 8601 formatted string", str, [])]),
)
Ok(date)
}

View file

@ -19,12 +19,13 @@ pub fn log_raw_send(req: og_fetch.FetchRequest) {
io.println("Sending raw request:") io.println("Sending raw request:")
io.debug(req) io.debug(req)
use resp <- promise.try_await(og_fetch.raw_send(req)) use resp <- promise.try_await(og_fetch.raw_send(req))
use resp <- promise.try_await(og_fetch.read_text_body(og_fetch.from_fetch_response(
resp,
)))
io.println(string.slice(string.inspect(resp), 0, debug_print_len)) io.println(string.slice(string.inspect(resp), 0, debug_print_len))
promise.resolve(Ok(resp)) promise.resolve(Ok(resp))
} }
@external(javascript, "../../fetch_ffi.mjs", "url")
pub fn url(resp: og_fetch.FetchResponse) -> String
@external(javascript, "../../fetch_ffi.mjs", "preventRedirection") @external(javascript, "../../fetch_ffi.mjs", "preventRedirection")
pub fn prevent_redirection(req: og_fetch.FetchRequest) -> og_fetch.FetchRequest pub fn prevent_redirection(req: og_fetch.FetchRequest) -> og_fetch.FetchRequest

View file

@ -32,8 +32,11 @@ pub fn auth(
b2c.authenticate(config, username, password) b2c.authenticate(config, username, password)
|> promise_helpers.map_error(AuthError), |> promise_helpers.map_error(AuthError),
) )
let now = date.unix_now()
use access_token_expires_in <- promise.try_await(promise.resolve( use access_token_expires_in <- promise.try_await(promise.resolve(
date.from_unix(tokens.access_token_expires_in) date.from_unix(now + tokens.access_token_expires_in * 1000)
|> result.replace_error(InvalidData( |> result.replace_error(InvalidData(
msg: "Access token expiry could not be converted into DateTime: " <> string.inspect( msg: "Access token expiry could not be converted into DateTime: " <> string.inspect(
tokens.access_token_expires_in, tokens.access_token_expires_in,
@ -41,7 +44,7 @@ pub fn auth(
)), )),
)) ))
use refresh_token_expires_in <- promise.try_await(promise.resolve( use refresh_token_expires_in <- promise.try_await(promise.resolve(
date.from_unix(tokens.refresh_token_expires_in) date.from_unix(now + tokens.refresh_token_expires_in * 1000)
|> result.replace_error(InvalidData( |> result.replace_error(InvalidData(
msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect( msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect(
tokens.refresh_token_expires_in, tokens.refresh_token_expires_in,

View file

@ -1,27 +1,158 @@
import gleam/option.{None, Option, Some}
import gleam/string
import gleam/javascript/promise
import lustre import lustre
import lustre/element/html.{div} import lustre/element/html
import lustre/effect.{Effect}
import lustre/element
import plinth/javascript/storage.{Storage}
import geo_t/config.{Config} import geo_t/config.{Config}
import geo_t/web/login_view import geo_t/web/login_view
import geo_t/web/installations_view
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/auth/api.{ApiError}
import geo_t/web/auth.{AuthInfo, AuthInfoStorage}
pub fn main() { pub fn main() {
let app = lustre.simple(init, update, view) let app = lustre.application(init, update, view)
let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil) let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil)
Nil Nil
} }
pub type Model { pub type Model {
Model(config: Config, login: login_view.Model) Model(
config: Config,
local_storage: Storage,
login: login_view.Model,
installations: installations_view.Model,
auth_storage: AuthInfoStorage,
auth: Option(AuthInfo),
logging_in: Bool,
logged_in: Bool,
)
}
pub type Msg {
LoginView(login_view.Msg)
LoginResult(Result(User, ApiError))
InstallationsView(installations_view.Msg)
} }
fn init(_) { fn init(_) {
Model(config: config.load_config(), login: login_view.init()) let config = config.load_config()
let assert Ok(local) = storage.local()
let auth_storage = auth.new_storage(config, local)
let auth_info = case auth.load(auth_storage) {
Ok(info) -> Some(info)
Error(_) -> None
}
#(
Model(
config: config,
login: login_view.init(),
installations: installations_view.init(),
local_storage: local,
auth_storage: auth_storage,
auth: auth_info,
logging_in: False,
logged_in: option.is_some(auth_info),
),
effect.none(),
)
} }
fn update(model: Model, _msg) { fn update(model: Model, msg: Msg) {
model case msg {
LoginView(login_view.AttemptLogin) -> {
#(
Model(
..model,
logging_in: True,
login: login_view.update(model.login, login_view.AttemptLogin),
),
login(model),
)
}
LoginView(other) -> #(
Model(..model, login: login_view.update(model.login, other)),
effect.none(),
)
LoginResult(Ok(user)) -> {
let auth_info = AuthInfo(user: user)
let _ = auth.store(model.auth_storage, auth_info)
let #(inst_model, inst_effect) =
update_installations(
model,
installations_view.LoadInstallations(model.config, user),
)
#(
Model(
..model,
logging_in: False,
logged_in: True,
auth: Some(auth_info),
installations: inst_model,
),
inst_effect,
)
}
LoginResult(Error(error)) -> #(
Model(
..model,
logging_in: False,
logged_in: False,
auth: None,
login: login_view.update(
model.login,
login_view.LoginFailed(string.inspect(error)),
),
),
effect.none(),
)
InstallationsView(msg) -> {
let #(new_model, new_effect) = update_installations(model, msg)
#(Model(..model, installations: new_model), new_effect)
}
}
} }
fn view(model: Model) { fn view(model: Model) {
div([], [login_view.view(model.login)]) html.div(
[],
[
case model.logged_in {
False ->
login_view.view(model.login)
|> element.map(LoginView)
True ->
installations_view.view(model.installations)
|> element.map(InstallationsView)
},
],
)
}
fn login(model: Model) -> Effect(Msg) {
use dispatch <- effect.from()
api.auth(model.config, model.login.username, model.login.password)
|> promise.map(LoginResult)
|> promise.tap(dispatch)
Nil
}
fn update_installations(model: Model, msg: installations_view.Msg) {
let #(new_model, new_effect) =
installations_view.update(model.installations, msg)
let new_effect = effect.map(new_effect, fn(msg) { InstallationsView(msg) })
#(new_model, new_effect)
} }

105
src/geo_t/web/auth.gleam Normal file
View file

@ -0,0 +1,105 @@
import gleam/result
import gleam/dynamic.{DecodeError, Dynamic}
import gleam/json.{Json}
import gleam/order
import geo_t/pump_api/auth/user.{User}
import geo_t/pump_api/auth/tokens.{Tokens}
import geo_t/helpers/date
import geo_t/config.{Config}
import varasto.{TypedStorage}
import plinth/javascript/storage.{Storage}
const storage_key = "auth_info"
pub type AuthInfo {
AuthInfo(user: User)
}
pub type AuthInfoStorage {
AuthInfoStorage(storage: TypedStorage(AuthInfo), key: String)
}
pub type LoadResult {
LoadError(err: varasto.ReadError)
ExpiredError
}
pub fn new_storage(config: Config, plinth_storage: Storage) -> AuthInfoStorage {
AuthInfoStorage(
storage: varasto.new(plinth_storage, read, write),
key: config.local_storage_prefix <> storage_key,
)
}
pub fn load(storage: AuthInfoStorage) {
use info <- result.try(
varasto.get(storage.storage, storage.key)
|> result.map_error(LoadError),
)
case date.compare(date.now(), info.user.tokens.access_token_expiry) {
order.Lt -> Ok(info)
_ -> Error(ExpiredError)
}
}
pub fn store(storage: AuthInfoStorage, value: AuthInfo) {
varasto.set(storage.storage, storage.key, value)
}
fn write(info: AuthInfo) -> Json {
json.object([#("user", write_user(info.user))])
}
fn write_user(user: User) -> Json {
json.object([#("tokens", write_tokens(user.tokens))])
}
fn write_tokens(tokens: Tokens) -> Json {
json.object([
#("access_token", json.string(tokens.access_token)),
#(
"access_token_expiry",
json.string(date.to_iso8601(tokens.access_token_expiry)),
),
#("refresh_token", json.string(tokens.refresh_token)),
#(
"refresh_token_expiry",
json.string(date.to_iso8601(tokens.refresh_token_expiry)),
),
])
}
fn read(value: Dynamic) -> Result(AuthInfo, List(DecodeError)) {
use user <- result.try(dynamic.field("user", read_user)(value))
Ok(AuthInfo(user: user))
}
fn read_user(value: Dynamic) -> Result(User, List(DecodeError)) {
use tokens <- result.try(dynamic.field("tokens", read_tokens)(value))
Ok(User(tokens: tokens))
}
fn read_tokens(value: Dynamic) -> Result(Tokens, List(DecodeError)) {
use access_token <- result.try(dynamic.field("access_token", dynamic.string)(
value,
))
use access_token_expiry <- result.try(dynamic.field(
"access_token_expiry",
date.decode,
)(value))
use refresh_token <- result.try(dynamic.field("refresh_token", dynamic.string)(
value,
))
use refresh_token_expiry <- result.try(dynamic.field(
"refresh_token_expiry",
date.decode,
)(value))
Ok(Tokens(
access_token: access_token,
access_token_expiry: access_token_expiry,
refresh_token: refresh_token,
refresh_token_expiry: refresh_token_expiry,
))
}

View file

@ -0,0 +1,82 @@
import gleam/list
import gleam/int
import gleam/string
import gleam/javascript/promise
import lustre/element.{Element, text}
import lustre/element/html
import lustre/attribute
import lustre/event
import lustre/effect.{Effect}
import geo_t/config.{Config}
import geo_t/pump_api/auth/installation_info.{InstallationInfo}
import geo_t/pump_api/auth/api.{ApiError}
import geo_t/pump_api/auth/user.{User}
type InstallationData =
Result(List(InstallationInfo), ApiError)
pub type Model {
Model(loading: Bool, installations: InstallationData)
}
pub fn init() {
Model(loading: False, installations: Ok([]))
}
pub type Msg {
LoadInstallations(Config, User)
InstallationsResult(Result(List(InstallationInfo), ApiError))
}
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
LoadInstallations(config, user) -> #(
Model(..model, loading: True),
load_installations(config, user),
)
InstallationsResult(data) -> #(
Model(loading: False, installations: data),
effect.none(),
)
}
}
pub fn view(model: Model) -> Element(Msg) {
html.div(
[],
[
html.h1([], [text("Installations")]),
case model.installations {
Ok(installations) ->
html.ul(
[],
list.map(
installations,
fn(installation) {
html.button(
[attribute.type_("button")],
[text(int.to_string(installation.id))],
)
},
),
)
Error(err) ->
html.p(
[],
[text("An error occurred while loading: " <> string.inspect(err))],
)
},
],
)
}
fn load_installations(config: Config, user: User) {
use dispatch <- effect.from()
api.installation_info(config, user)
|> promise.map(InstallationsResult)
|> promise.tap(dispatch)
Nil
}

View file

@ -1,23 +1,37 @@
import gleam/int
import lustre/element.{Element} import lustre/element.{Element}
import lustre/element/html import lustre/element/html
import lustre/attribute import lustre/attribute
import lustre/event import lustre/event
pub type Model { pub type Model {
Model(username: String, password: String) Model(
username: String,
password: String,
logging_in: Bool,
login_failed: Bool,
error: String,
)
} }
pub fn init() -> Model { pub fn init() -> Model {
Model("", "") Model("", "", False, False, "")
} }
pub type Msg { pub type Msg {
AttemptLogin AttemptLogin
LoginFailed(String)
OnUsernameInput(String)
OnPasswordInput(String)
} }
pub fn update(model: Model, msg: Msg) -> Model { pub fn update(model: Model, msg: Msg) -> Model {
model case msg {
OnUsernameInput(username) -> Model(..model, username: username)
OnPasswordInput(password) -> Model(..model, password: password)
AttemptLogin -> Model(..model, logging_in: True)
LoginFailed(error) ->
Model(..model, login_failed: True, logging_in: False, error: error)
}
} }
pub fn view(model: Model) -> Element(Msg) { pub fn view(model: Model) -> Element(Msg) {
@ -26,21 +40,40 @@ pub fn view(model: Model) -> Element(Msg) {
[ [
html.h1([], [element.text("Log in")]), html.h1([], [element.text("Log in")]),
html.form( html.form(
[attribute.attribute("method", "post")], [attribute.attribute("method", "post"), event.on_submit(AttemptLogin)],
[ [
html.input([ html.input([
attribute.type_("text"), attribute.type_("text"),
attribute.name("username"), attribute.name("username"),
attribute.required(True), attribute.required(True),
attribute.disabled(model.logging_in),
event.on_input(OnUsernameInput),
]), ]),
html.input([ html.input([
attribute.type_("password"), attribute.type_("password"),
attribute.name("password"), attribute.name("password"),
attribute.required(True), attribute.required(True),
attribute.disabled(model.logging_in),
event.on_input(OnPasswordInput),
]), ]),
html.button([attribute.type_("submit")], [element.text("Log in")]), html.button(
[attribute.type_("submit"), attribute.disabled(model.logging_in)],
[element.text(submit_text(model))],
),
], ],
), ),
case model.login_failed {
True ->
html.p([], [element.text("Login failed, because: " <> model.error)])
False -> element.text("")
},
], ],
) )
} }
fn submit_text(model: Model) {
case model.logging_in {
True -> "Logging in…"
False -> "Log in"
}
}