Various changes (mostly finish logins)

This commit is contained in:
Alexander Rosenberg 2023-10-23 15:00:11 -07:00
parent 94801968fa
commit 67440ccca1
Signed by: Zander671
GPG Key ID: 5FD0394ADBD72730
9 changed files with 377 additions and 115 deletions

View File

@ -7,5 +7,6 @@ edition = "2021"
[dependencies] [dependencies]
gtk4 = { version = "0.7.3", features = ["blueprint", "v4_12"] } gtk4 = { version = "0.7.3", features = ["blueprint", "v4_12"] }
once_cell = "1.18.0"
simplelogin = { version = "0.1.0", path = "../simplelogin" } simplelogin = { version = "0.1.0", path = "../simplelogin" }
tokio = { version = "1.33.0", features = ["full"] } tokio = { version = "1.33.0", features = ["full"] }

View File

@ -1,6 +1,6 @@
use std::{env, io, fs, path::Path, process::Command}; use std::{env, io, fs, path::Path, process::Command};
const SCHEMA_NAME: &'static str = "im.zander.SimpleLogin.gschema.xml"; const SCHEMA_NAME: &str = "im.zander.SimpleLogin.gschema.xml";
fn main() -> io::Result<()> { fn main() -> io::Result<()> {
println!("cargo:rerun-if-changed=data/{}", SCHEMA_NAME); println!("cargo:rerun-if-changed=data/{}", SCHEMA_NAME);

View File

@ -1,28 +1,16 @@
using Gtk 4.0; using Gtk 4.0;
Popover device_entry_popover {
child: Label {
accessible-role: tooltip;
label: "This is the human readable name that will show up in the API key section of the SimpleLogin website.";
max-width-chars: 30;
wrap: true;
}
;
}
template $LoginWindow : ApplicationWindow { template $LoginWindow : ApplicationWindow {
title: "SimpleLogin"; title: "SimpleLogin";
Box { Overlay forms_overlay {
orientation: vertical; valign: center;
vexpand: true;
Overlay forms_overlay { child: Box forms_wrapper {
child: forms_wrapper; orientation: vertical;
valign: end;
vexpand: true;
Box forms_wrapper { Box {
valign: end; valign: end;
vexpand: true; vexpand: true;
@ -55,6 +43,7 @@ template $LoginWindow : ApplicationWindow {
Label error_label { Label error_label {
label: "ERROR"; label: "ERROR";
margin-top: 5; margin-top: 5;
visible: false;
styles [ styles [
"error-message-label", "error-message-label",
@ -73,11 +62,23 @@ template $LoginWindow : ApplicationWindow {
secondary-icon-name: "dialog-question-symbolic"; secondary-icon-name: "dialog-question-symbolic";
truncate-multiline: true; truncate-multiline: true;
width-chars: 18; width-chars: 18;
icon-release => $device_help_clicked() swapped;
changed => $login_form_changed() swapped;
activate => $login_form_submit() swapped;
layout { layout {
column: "1"; column: "1";
row: "3"; row: "3";
} }
Popover device_entry_popover {
child: Label {
accessible-role: tooltip;
label: "This is the human readable name that will show up in the API key section of the SimpleLogin website.";
max-width-chars: 30;
wrap: true;
};
}
} }
Label { Label {
@ -116,6 +117,8 @@ template $LoginWindow : ApplicationWindow {
margin-start: 6; margin-start: 6;
receives-default: true; receives-default: true;
truncate-multiline: true; truncate-multiline: true;
changed => $login_form_changed() swapped;
activate => $login_form_submit() swapped;
layout { layout {
column: "1"; column: "1";
@ -127,6 +130,8 @@ template $LoginWindow : ApplicationWindow {
hexpand: true; hexpand: true;
margin-start: 6; margin-start: 6;
show-peek-icon: true; show-peek-icon: true;
changed => $login_form_changed() swapped;
activate => $login_form_submit() swapped;
layout { layout {
column: "1"; column: "1";
@ -140,6 +145,7 @@ template $LoginWindow : ApplicationWindow {
margin-top: 5; margin-top: 5;
sensitive: false; sensitive: false;
valign: center; valign: center;
clicked => $login_form_submit() swapped;
layout { layout {
column: "0"; column: "0";
@ -185,6 +191,8 @@ template $LoginWindow : ApplicationWindow {
margin-start: 8; margin-start: 8;
show-peek-icon: true; show-peek-icon: true;
width-chars: 27; width-chars: 27;
changed => $api_key_form_changed() swapped;
activate => $api_key_form_submit() swapped;
} }
Button api_key_button { Button api_key_button {
@ -192,46 +200,50 @@ template $LoginWindow : ApplicationWindow {
label: "Accept"; label: "Accept";
margin-top: 5; margin-top: 5;
sensitive: false; sensitive: false;
clicked => $api_key_form_submit() swapped;
} }
} }
} }
}
Label {
label: "Don\'t have an account? You can register <a href=\"https://app.simplelogin.io/auth/register\">here</a>."; Label {
label: "Don\'t have an account? You can register <a href=\"https://app.simplelogin.io/auth/register\">here</a>.";
margin-bottom: 10; margin-bottom: 10;
use-markup: true; use-markup: true;
valign: start; valign: start;
vexpand: true; vexpand: true;
yalign: 0.0; yalign: 0.0;
}
};
[overlay]
Box loading_overlay {
orientation: vertical;
visible: false;
Spinner {
spinning: true;
valign: end;
vexpand: true;
styles [
"loading-label",
]
}
Label {
label: "Loading...";
valign: start;
vexpand: true;
styles [
"loading-label",
]
}
styles [
"loading-label",
]
} }
} }
} }
Box loading_overlay {
orientation: vertical;
Spinner {
spinning: true;
valign: end;
vexpand: true;
styles [
"loading-label",
]
}
Label {
label: "Loading...";
valign: start;
vexpand: true;
styles [
"loading-label",
]
}
styles [
"loading-label",
]
}

View File

@ -5,13 +5,12 @@ template $TotpWindow : Window {
title: "TOTP Entry"; title: "TOTP Entry";
Overlay overlay { Overlay overlay {
child: form_wrapper;
halign: center; halign: center;
hexpand: true; hexpand: true;
valign: center; valign: center;
vexpand: true; vexpand: true;
Grid form_wrapper { child: Grid form_wrapper {
column-spacing: 5; column-spacing: 5;
halign: center; halign: center;
hexpand: true; hexpand: true;
@ -49,6 +48,7 @@ template $TotpWindow : Window {
Button accept_button { Button accept_button {
label: "Accept"; label: "Accept";
clicked => $accepted() swapped;
layout { layout {
column: "1"; column: "1";
@ -58,6 +58,7 @@ template $TotpWindow : Window {
Button cancel_button { Button cancel_button {
label: "Cancel"; label: "Cancel";
clicked => $canceled() swapped;
layout { layout {
column: "0"; column: "0";
@ -67,6 +68,7 @@ template $TotpWindow : Window {
Entry code_entry { Entry code_entry {
placeholder-text: "TOTP Code"; placeholder-text: "TOTP Code";
changed => $totp_changed() swapped;
layout { layout {
column: "0"; column: "0";
@ -77,6 +79,7 @@ template $TotpWindow : Window {
Label error_label { Label error_label {
label: "ERROR"; label: "ERROR";
visible: false;
styles [ styles [
"error-message-label", "error-message-label",
@ -88,34 +91,37 @@ template $TotpWindow : Window {
row: "2"; row: "2";
} }
} }
};
[overlay]
Box loading_overlay {
orientation: vertical;
visible: false;
Spinner {
spinning: true;
valign: end;
vexpand: true;
styles [
"loading-label",
]
}
Label {
label: "Loading...";
valign: start;
vexpand: true;
styles [
"loading-label",
]
}
styles [
"loading-label",
]
} }
} }
} }
Box loading_overlay {
orientation: vertical;
Spinner {
spinning: true;
valign: end;
vexpand: true;
styles [
"loading-label",
]
}
Label {
label: "Loading...";
valign: start;
vexpand: true;
styles [
"loading-label",
]
}
styles [
"loading-label",
]
}

View File

@ -2,10 +2,12 @@ use crate::login_window::LoginWindow;
use gtk4 as gtk; use gtk4 as gtk;
use gtk::{glib, gdk, gio, prelude::*, subclass::prelude::*}; use gtk::{glib, gdk, gio, prelude::*, subclass::prelude::*};
use std::cell::RefCell;
use simplelogin as sl;
mod imp { mod imp {
use super::*; use super::*;
use std::cell::RefCell; use glib::closure_local;
#[derive(Debug, glib::Properties)] #[derive(Debug, glib::Properties)]
#[properties(wrapper_type = super::Application)] #[properties(wrapper_type = super::Application)]
@ -13,6 +15,7 @@ mod imp {
#[property(name = "api-key", type = Option<String>, #[property(name = "api-key", type = Option<String>,
get = Self::api_key, set = Self::set_api_key, nullable)] get = Self::api_key, set = Self::set_api_key, nullable)]
pub settings: RefCell<gio::Settings>, pub settings: RefCell<gio::Settings>,
pub context: RefCell<sl::Context>,
} }
impl Application { impl Application {
@ -21,14 +24,39 @@ mod imp {
} }
fn set_api_key(&self, value: Option<String>) { fn set_api_key(&self, value: Option<String>) {
_ = self.settings.borrow().set("api-key", value).unwrap(); _ = self.settings.borrow().set("api-key", value);
}
fn activate_no_api_key(&self) {
let login_window = LoginWindow::new(&self.obj());
login_window.connect_closure(
"logged-in", false,
closure_local!(@weak-allow-none self as opt_this
=> move |win: LoginWindow| {
win.close();
if let Some(this) = opt_this {
let obj = this.obj();
let ctx = obj.context().borrow();
let api_key = ctx.api_key();
this.set_api_key(api_key.clone());
this.activate_has_api_key();
}
}));
login_window.present();
}
fn activate_has_api_key(&self) {
println!("Logged in with API Key: {}", self.api_key().unwrap());
} }
} }
impl Default for Application { impl Default for Application {
fn default() -> Self { fn default() -> Self {
let settings = RefCell::new(gio::Settings::new(crate::APP_ID));
let api_key: Option<String> = settings.borrow().value("api-key").get();
Self { Self {
settings: RefCell::new(gio::Settings::new(crate::APP_ID)), settings,
context: RefCell::new(sl::Context::new(api_key.as_deref())),
} }
} }
} }
@ -42,17 +70,15 @@ mod imp {
impl ApplicationImpl for Application { impl ApplicationImpl for Application {
fn activate(&self) { fn activate(&self) {
let css = gtk::CssProvider::new(); let css = gtk::CssProvider::new();
gtk::style_context_add_provider_for_display( gtk::style_context_add_provider_for_display(
&gdk::Display::default().unwrap(), &gdk::Display::default().unwrap(),
&css, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION &css, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION
); );
css.load_from_string(include_str!("../data/global.css")); css.load_from_string(include_str!("../data/global.css"));
if self.api_key().is_none() { match self.api_key() {
let login_window = LoginWindow::new(&self.obj()); Some(_) => self.activate_has_api_key(),
login_window.present(); None => self.activate_no_api_key(),
} else {
todo!();
} }
} }
} }
@ -72,4 +98,8 @@ impl Application {
pub fn new(app_id: &str) -> Self { pub fn new(app_id: &str) -> Self {
glib::Object::builder().property("application-id", app_id).build() glib::Object::builder().property("application-id", app_id).build()
} }
pub fn context(&self) -> &RefCell<sl::Context> {
&self.imp().context
}
} }

View File

@ -1,8 +1,11 @@
use gtk4 as gtk; use gtk4 as gtk;
use gtk::{glib, gio, subclass::prelude::*}; use gtk::{glib, gio, prelude::*, subclass::prelude::*};
mod imp { mod imp {
use crate::{application, totp_window};
use super::*; use super::*;
use glib::{subclass::Signal, clone, closure_local};
use once_cell::sync::Lazy;
#[derive(Debug, Default, gtk::CompositeTemplate)] #[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(file = "data/login_window.blp")] #[template(file = "data/login_window.blp")]
@ -31,6 +34,106 @@ mod imp {
pub loading_overlay: TemplateChild<gtk::Box>, pub loading_overlay: TemplateChild<gtk::Box>,
} }
#[gtk::template_callbacks]
impl LoginWindow {
#[template_callback]
fn login_form_changed(&self, _: &gtk::Widget) {
let email = &self.email_entry.get().text();
let password = &self.password_entry.get().text();
let device = &self.device_entry.get().text();
self.login_button.get().set_sensitive(
!email.is_empty() && !password.is_empty() && !device.is_empty())
}
fn set_error(&self, error: &str) {
self.set_is_loading(false);
let error_label = &self.error_label.get();
error_label.set_text(error);
error_label.set_visible(true);
}
fn set_is_loading(&self, loading: bool) {
if loading {
self.error_label.get().set_visible(false);
}
self.loading_overlay.get().set_visible(loading);
self.forms_wrapper.get().set_sensitive(!loading);
}
fn handle_need_totp(&self, app: &application::Application) {
let totp_window = totp_window::TotpWindow::new(app);
totp_window.connect_closure(
"totp-success", false,
closure_local!(@weak-allow-none self as opt_this =>
move |_: totp_window::TotpWindow| {
if let Some(this) = opt_this {
this.obj().emit_by_name::<()>("logged-in", &[]);
}
}));
totp_window.connect_closure(
"totp-failed", false,
closure_local!(@weak-allow-none self as opt_this
=> move |_: totp_window::TotpWindow, err: &str| {
if let Some(this) = opt_this {
this.set_error(err);
}
}));
totp_window.present();
}
#[template_callback]
fn login_form_submit(&self, _: &gtk::Widget) {
let main_loop = glib::MainContext::default();
main_loop.spawn_local(clone!(@weak self as this => async move {
let email = &this.email_entry.get().text();
let password = &this.password_entry.get().text();
let device = &this.device_entry.get().text();
if !email.is_empty() && !password.is_empty() && !device.is_empty() {
this.set_is_loading(true);
let app: application::Application =
this.obj().application().unwrap().downcast().unwrap();
let mut ctx = app.context().borrow_mut();
let login_res = ctx.login(email, password, device).await;
drop(ctx);
match login_res {
Ok(need_mfa) if need_mfa => this.handle_need_totp(&app),
Ok(_) => this.obj().emit_by_name::<()>("logged-in", &[]),
Err(e) => this.set_error(e.to_string().as_str()),
}
}
}));
}
fn get_api_key_from_field(&self) -> String {
self.api_key_entry.get().text().replace('\n', "")
}
#[template_callback]
fn api_key_form_changed(&self, _: &gtk::Widget) {
let api_key = self.get_api_key_from_field();
self.api_key_button.get().set_sensitive(!api_key.is_empty());
}
#[template_callback]
fn api_key_form_submit(&self, _: &gtk::Widget) {
let api_key = self.get_api_key_from_field();
if !api_key.is_empty() {
let obj = self.obj();
let app: application::Application =
obj.application().unwrap().downcast().unwrap();
let mut ctx = app.context().borrow_mut();
ctx.set_api_key(Some(api_key.as_str()));
drop(ctx);
obj.emit_by_name::<()>("logged-in", &[]);
}
}
#[template_callback]
fn device_help_clicked(&self, _: gtk::EntryIconPosition) {
self.device_entry_popover.get().popup();
}
}
#[glib::object_subclass] #[glib::object_subclass]
impl ObjectSubclass for LoginWindow { impl ObjectSubclass for LoginWindow {
const NAME: &'static str = "LoginWindow"; const NAME: &'static str = "LoginWindow";
@ -39,6 +142,7 @@ mod imp {
fn class_init(class: &mut Self::Class) { fn class_init(class: &mut Self::Class) {
class.bind_template(); class.bind_template();
class.bind_template_callbacks();
} }
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) { fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
@ -46,7 +150,18 @@ mod imp {
} }
} }
impl ObjectImpl for LoginWindow {} impl ObjectImpl for LoginWindow {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(||
vec![Signal::builder("logged-in").build()]);
SIGNALS.as_ref()
}
fn constructed(&self) {
self.parent_constructed();
}
}
impl WidgetImpl for LoginWindow {} impl WidgetImpl for LoginWindow {}
impl WindowImpl for LoginWindow {} impl WindowImpl for LoginWindow {}
impl ApplicationWindowImpl for LoginWindow {} impl ApplicationWindowImpl for LoginWindow {}

View File

@ -8,7 +8,8 @@ use gtk::glib::{self, g_info};
const APP_ID: &str = "im.zander.SimpleLogin"; const APP_ID: &str = "im.zander.SimpleLogin";
fn main() -> glib::ExitCode { #[tokio::main]
async fn main() -> glib::ExitCode {
#[cfg(debug_assertions)] { #[cfg(debug_assertions)] {
std::env::set_var("G_MESSAGES_DEBUG", "SimpleLogin"); std::env::set_var("G_MESSAGES_DEBUG", "SimpleLogin");
// allow us to use a test schema for debug runs // allow us to use a test schema for debug runs

View File

@ -2,12 +2,84 @@ use gtk4 as gtk;
use gtk::{glib, gio, prelude::*, subclass::prelude::*}; use gtk::{glib, gio, prelude::*, subclass::prelude::*};
mod imp { mod imp {
use crate::application;
use super::*; use super::*;
use once_cell::sync::Lazy;
use glib::{clone, subclass::Signal};
#[derive(Debug, Default, gtk::CompositeTemplate)] #[derive(Debug, Default, gtk::CompositeTemplate)]
#[template(file = "data/totp_window.blp")] #[template(file = "data/totp_window.blp")]
pub struct TotpWindow { pub struct TotpWindow {
#[template_child]
pub overlay: gtk::TemplateChild<gtk::Overlay>,
#[template_child]
pub form_wrapper: gtk::TemplateChild<gtk::Grid>,
#[template_child]
pub accept_button: gtk::TemplateChild<gtk::Button>,
#[template_child]
pub cancel_button: gtk::TemplateChild<gtk::Button>,
#[template_child]
pub code_entry: gtk::TemplateChild<gtk::Entry>,
#[template_child]
pub error_label: gtk::TemplateChild<gtk::Label>,
#[template_child]
pub loading_overlay: gtk::TemplateChild<gtk::Box>,
}
#[gtk::template_callbacks]
impl TotpWindow {
#[template_callback]
fn totp_changed(&self, _: &gtk::Entry) {
let totp = &self.code_entry.get().text();
self.accept_button.get().set_sensitive(!totp.is_empty());
}
#[template_callback]
fn canceled(&self, _: &gtk::Button) {
self.obj().close();
}
#[template_callback]
fn accepted(&self, _: &gtk::Button) {
let main_loop = glib::MainContext::default();
main_loop.spawn_local(clone!(@weak self as this => async move {
let obj = this.obj();
this.set_is_loading(true);
this.loading_overlay.get().set_visible(true);
let app: application::Application =
obj.application().unwrap().downcast().unwrap();
let mut ctx = app.context().borrow_mut();
let totp = &this.code_entry.get().text();
let mfa_res = ctx.mfa(totp).await;
drop(ctx);
match mfa_res {
Ok(()) => {
obj.emit_by_name::<()>("totp-success", &[]);
obj.close();
},
Err(e) if e.is_json() => this.set_error(e.to_string().as_str()),
Err(e) => {
obj.emit_by_name::<()>("totp-failed", &[&e.to_string()]);
obj.close();
},
}
}));
}
fn set_error(&self, error: &str) {
self.set_is_loading(false);
let error_label = &self.error_label.get();
error_label.set_text(error);
error_label.set_visible(true);
}
fn set_is_loading(&self, loading: bool) {
if loading {
self.error_label.get().set_visible(false);
}
self.loading_overlay.get().set_visible(loading);
self.form_wrapper.get().set_sensitive(!loading);
}
} }
#[glib::object_subclass] #[glib::object_subclass]
@ -18,6 +90,7 @@ mod imp {
fn class_init(class: &mut Self::Class) { fn class_init(class: &mut Self::Class) {
class.bind_template(); class.bind_template();
class.bind_template_callbacks();
} }
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) { fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
@ -25,7 +98,22 @@ mod imp {
} }
} }
impl ObjectImpl for TotpWindow {} impl ObjectImpl for TotpWindow {
fn signals() -> &'static [Signal] {
static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(||
vec![Signal::builder("totp-success").build(),
Signal::builder("totp-failed")
.param_types([str::static_type()])
.build()]);
SIGNALS.as_ref()
}
fn constructed(&self) {
self.parent_constructed();
self.overlay.add_overlay(&self.loading_overlay.get());
}
}
impl WidgetImpl for TotpWindow {} impl WidgetImpl for TotpWindow {}
impl WindowImpl for TotpWindow {} impl WindowImpl for TotpWindow {}
} }

View File

@ -1,8 +1,9 @@
#![allow(dead_code)] #![allow(dead_code)]
use core::fmt; use std::fmt::{self, Display};
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
#[derive(Debug)]
pub struct Context { pub struct Context {
client: reqwest::Client, client: reqwest::Client,
api_key: Option<String>, api_key: Option<String>,
@ -13,6 +14,16 @@ pub struct Context {
device: Option<String>, device: Option<String>,
} }
impl Display for Context {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
let api_key = match &self.api_key {
Some(key) => format!("\"{}\"", key),
None => "None".to_owned(),
};
writeln!(f, "Context(api_key: \"{}\", url: \"{}\")", api_key, self.url)
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct ErrorResponse { struct ErrorResponse {
error: String, error: String,
@ -113,15 +124,15 @@ impl Context {
} }
} }
pub fn api_key(&self) -> Option<&String> { pub fn api_key(&self) -> &Option<String> {
self.api_key.as_ref() &self.api_key
} }
pub fn set_api_key(&mut self, api_key: Option<&str>) { pub fn set_api_key(&mut self, api_key: Option<&str>) {
self.api_key = api_key.map(|s| s.to_owned()); self.api_key = api_key.map(|s| s.to_owned());
} }
fn parse_error_json(error_body: &String) -> String { fn parse_error_json(error_body: &str) -> String {
match serde_json::from_str(error_body) { match serde_json::from_str(error_body) {
Ok::<ErrorResponse, _>(obj) => obj.error, Ok::<ErrorResponse, _>(obj) => obj.error,
Err(_) => "Could not parse error response JSON".to_owned(), Err(_) => "Could not parse error response JSON".to_owned(),
@ -136,7 +147,7 @@ impl Context {
let status = resp.status(); let status = resp.status();
let resp_text = resp.text().await let resp_text = resp.text().await
.map_err(|e| Error::new(ErrorKind::Http, e.to_string()))?; .map_err(|e| Error::new(ErrorKind::Http, e.to_string()))?;
return if status.is_success() { if status.is_success() {
Ok(resp_text) Ok(resp_text)
} else if status.is_client_error() { } else if status.is_client_error() {
Err(Error::new(ErrorKind::Auth, Self::parse_error_json(&resp_text))) Err(Error::new(ErrorKind::Auth, Self::parse_error_json(&resp_text)))
@ -190,15 +201,13 @@ impl Context {
} else { } else {
Err(Error::new(ErrorKind::Json, "Error response did not contain mfa key")) Err(Error::new(ErrorKind::Json, "Error response did not contain mfa key"))
} }
} else if resp_obj.api_key.is_some() {
self.api_key = resp_obj.api_key;
self.mfa_key = None;
self.device = None;
Ok(true)
} else { } else {
if resp_obj.api_key.is_some() { Err(Error::new(ErrorKind::Json, "Response did not contain api key"))
self.api_key = resp_obj.api_key;
self.mfa_key = None;
self.device = None;
Ok(true)
} else {
Err(Error::new(ErrorKind::Json, "Response did not contain api key"))
}
} }
} }