Functional login and fetching of installations
This commit is contained in:
parent
d21a5fb99f
commit
28c09b5086
17 changed files with 471 additions and 41 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,3 +7,5 @@ src/geo_therminator.gleam
|
|||
/old
|
||||
|
||||
/priv/config.mjs
|
||||
|
||||
/chrome-data-dir
|
||||
|
|
|
@ -9,3 +9,4 @@ gleam_json = "~> 0.6.0"
|
|||
gleam_javascript = "~> 0.6.0"
|
||||
gleam_fetch = "~> 0.2.0"
|
||||
lustre = "~> 3.0"
|
||||
varasto = "~> 1.0"
|
||||
|
|
|
@ -2,13 +2,15 @@
|
|||
# You typically do not need to edit this file
|
||||
|
||||
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_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_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 = "varasto", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_json", "gleam_stdlib", "plinth"], otp_app = "varasto", source = "hex", outer_checksum = "0621E5BFD0B9B7F7D19B8FC6369C6E2EAC5C1F3858A1E5E51342F5BCE10C3728" },
|
||||
]
|
||||
|
||||
[requirements]
|
||||
|
@ -18,3 +20,4 @@ gleam_javascript = { version = "~> 0.6.0" }
|
|||
gleam_json = { version = "~> 0.6.0" }
|
||||
gleam_stdlib = { version = "~> 0.30" }
|
||||
lustre = { version = "~> 3.0" }
|
||||
varasto = { version = "~> 1.0" }
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
<title>GeoTherminator</title>
|
||||
|
||||
<script type="module">
|
||||
import "./build/dev/javascript/geo_therminator/priv/config.mjs";
|
||||
import { main } from "./build/dev/javascript/geo_therminator/geo_t/web.mjs";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Ok, Error, BitString } from "./gleam.mjs";
|
||||
|
||||
let crypto;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export async function hash(algo, data) {
|
||||
if (!crypto) {
|
||||
|
@ -11,6 +12,8 @@ export async function hash(algo, data) {
|
|||
}
|
||||
}
|
||||
|
||||
data = encoder.encode(data);
|
||||
|
||||
try {
|
||||
const hash = await crypto.subtle.digest(algo, data);
|
||||
const hashArray = new Uint8Array(hash);
|
||||
|
|
|
@ -12,3 +12,23 @@ function construct(input) {
|
|||
|
||||
export const from_iso8601 = 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;
|
||||
}
|
||||
|
|
|
@ -3,3 +3,7 @@ export function preventRedirection(request) {
|
|||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
|
||||
export function url(resp) {
|
||||
return resp.url;
|
||||
}
|
||||
|
|
|
@ -15,6 +15,7 @@ import gleam/list
|
|||
import gleam/result
|
||||
import gleam/int
|
||||
import gleam/fetch
|
||||
import gleam/bool
|
||||
import gleam/javascript/promise.{Promise}
|
||||
import geo_t/azure/utils
|
||||
import geo_t/helpers/crypto
|
||||
|
@ -178,14 +179,7 @@ fn confirm(
|
|||
self_asserted: SelfAsserted,
|
||||
auth_info: AuthInfo,
|
||||
) -> Promise(Result(Confirmed, B2CError)) {
|
||||
let csrf_cookie_key = "x-ms-cpim-csrf"
|
||||
|
||||
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 cookies = self_asserted.cookies
|
||||
let req = build_req(confirm_url(config), http.Get)
|
||||
|
||||
let req =
|
||||
|
@ -199,26 +193,23 @@ fn confirm(
|
|||
..base_query(auth_info)
|
||||
])
|
||||
|
||||
use resp <- promise.try_await(
|
||||
use raw_resp <- promise.try_await(
|
||||
req
|
||||
|> fetch.to_fetch_request()
|
||||
|> fetch_helpers.prevent_redirection()
|
||||
|> fetch_helpers.log_raw_send()
|
||||
|> request_error(),
|
||||
)
|
||||
|
||||
use resp <- promise.try_await(case resp.status {
|
||||
302 -> promise.resolve(Ok(resp))
|
||||
error ->
|
||||
promise.resolve(Error(ContentError(
|
||||
msg: "Confirm HTTP request bad error code: " <> int.to_string(error),
|
||||
)))
|
||||
})
|
||||
let resp = fetch.from_fetch_response(raw_resp)
|
||||
|
||||
use location <- promise.try_await(promise.resolve(
|
||||
response.get_header(resp, "location")
|
||||
|> b2c_error("Location not found for confirm response."),
|
||||
))
|
||||
use <- bool.guard(
|
||||
when: resp.status != 200,
|
||||
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(
|
||||
location
|
||||
|
|
|
@ -26,6 +26,7 @@ pub type Config {
|
|||
b2c_auth_url: Uri,
|
||||
api_opstat_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,
|
||||
"https://thermialogin.b2clogin.com/thermialogin.onmicrosoft.com/b2c_1a_signuporsigninonline",
|
||||
),
|
||||
local_storage_prefix: get_env(
|
||||
config_helpers.local_storage_prefix,
|
||||
"__geo_therminator__",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -56,6 +56,10 @@ pub fn api_device_reg_set_client_id() {
|
|||
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)) {
|
||||
config_key
|
||||
|> get_conf()
|
||||
|
|
|
@ -1,7 +1,48 @@
|
|||
import gleam/result
|
||||
import gleam/order.{Eq, Gt, Lt, Order}
|
||||
import gleam/dynamic.{DecodeError, Dynamic}
|
||||
|
||||
pub type Date
|
||||
|
||||
@external(javascript, "../../date_ffi.mjs", "from_iso8601")
|
||||
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")
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -19,12 +19,13 @@ pub fn log_raw_send(req: og_fetch.FetchRequest) {
|
|||
io.println("Sending raw request:")
|
||||
io.debug(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))
|
||||
promise.resolve(Ok(resp))
|
||||
}
|
||||
|
||||
@external(javascript, "../../fetch_ffi.mjs", "url")
|
||||
pub fn url(resp: og_fetch.FetchResponse) -> String
|
||||
|
||||
@external(javascript, "../../fetch_ffi.mjs", "preventRedirection")
|
||||
pub fn prevent_redirection(req: og_fetch.FetchRequest) -> og_fetch.FetchRequest
|
||||
|
|
|
@ -32,8 +32,11 @@ pub fn auth(
|
|||
b2c.authenticate(config, username, password)
|
||||
|> promise_helpers.map_error(AuthError),
|
||||
)
|
||||
|
||||
let now = date.unix_now()
|
||||
|
||||
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(
|
||||
msg: "Access token expiry could not be converted into DateTime: " <> string.inspect(
|
||||
tokens.access_token_expires_in,
|
||||
|
@ -41,7 +44,7 @@ pub fn auth(
|
|||
)),
|
||||
))
|
||||
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(
|
||||
msg: "Refresh token expiry could not be converted into DateTime: " <> string.inspect(
|
||||
tokens.refresh_token_expires_in,
|
||||
|
|
|
@ -1,27 +1,158 @@
|
|||
import gleam/option.{None, Option, Some}
|
||||
import gleam/string
|
||||
import gleam/javascript/promise
|
||||
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/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() {
|
||||
let app = lustre.simple(init, update, view)
|
||||
let app = lustre.application(init, update, view)
|
||||
let assert Ok(_) = lustre.start(app, "[data-lustre-app]", Nil)
|
||||
|
||||
Nil
|
||||
}
|
||||
|
||||
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(_) {
|
||||
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
|
||||
}
|
||||
|
||||
fn update(model: Model, _msg) {
|
||||
model
|
||||
#(
|
||||
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: Msg) {
|
||||
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) {
|
||||
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
105
src/geo_t/web/auth.gleam
Normal 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,
|
||||
))
|
||||
}
|
82
src/geo_t/web/installations_view.gleam
Normal file
82
src/geo_t/web/installations_view.gleam
Normal 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
|
||||
}
|
|
@ -1,23 +1,37 @@
|
|||
import gleam/int
|
||||
import lustre/element.{Element}
|
||||
import lustre/element/html
|
||||
import lustre/attribute
|
||||
import lustre/event
|
||||
|
||||
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 {
|
||||
Model("", "")
|
||||
Model("", "", False, False, "")
|
||||
}
|
||||
|
||||
pub type Msg {
|
||||
AttemptLogin
|
||||
LoginFailed(String)
|
||||
OnUsernameInput(String)
|
||||
OnPasswordInput(String)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -26,21 +40,40 @@ pub fn view(model: Model) -> Element(Msg) {
|
|||
[
|
||||
html.h1([], [element.text("Log in")]),
|
||||
html.form(
|
||||
[attribute.attribute("method", "post")],
|
||||
[attribute.attribute("method", "post"), event.on_submit(AttemptLogin)],
|
||||
[
|
||||
html.input([
|
||||
attribute.type_("text"),
|
||||
attribute.name("username"),
|
||||
attribute.required(True),
|
||||
attribute.disabled(model.logging_in),
|
||||
event.on_input(OnUsernameInput),
|
||||
]),
|
||||
html.input([
|
||||
attribute.type_("password"),
|
||||
attribute.name("password"),
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue