Compare commits

...

25 Commits

Author SHA1 Message Date
11e33eca2f Set up a shared types library 2025-02-20 09:45:53 -05:00
fd3ca9f561 Set up the most basic of authentication clients 2025-02-20 07:39:35 -05:00
e8a8a12de3 Start capturing input 2025-02-19 22:39:42 -05:00
7f0b7982ec Switch from println to log from gloo-console 2025-02-18 23:23:46 -05:00
5e4fd97aca Set up some callbacks to handle the login page state 2025-02-18 23:18:23 -05:00
1c4894df9a Start on the client module 2025-02-18 21:36:01 -05:00
20b214df10 Start adding some concepts around UI state 2025-02-18 08:25:40 -05:00
ca89455d4d Set up a Yew login page 2025-02-17 23:03:12 -05:00
2ff981e28a Nuke another speculative UI 2025-02-17 21:51:14 -05:00
672578b9a9 Add a micro-prototype Yew application 2025-02-17 18:28:18 -05:00
fb2fcf4d36 Abortive attempt to set up a trivial web application 2025-02-17 16:19:33 -05:00
a1dc573fc5 Adjust all build processes 2025-02-17 15:44:01 -05:00
0d39690560 Start rebuilding the typescript config, this time for web components 2025-02-17 15:17:30 -05:00
9439cfea34 Purge the Vite/React application 2025-02-17 09:34:26 -05:00
1d050f014a Set up rudimentary state, App, and a test for the App 2025-02-17 08:48:46 -05:00
df1dfeaae3 Set up dependencies 2025-02-16 20:50:50 -05:00
8ab8cd0684 Set up a package for just the types 2025-02-16 20:22:20 -05:00
aa7229eae4 Rename VResponse to AuthResponse and use it only in Authentication 2025-02-16 19:41:05 -05:00
0663a70c97 Force the password-reset state to Unauthorized on most auth-required routes 2025-02-16 15:54:59 -05:00
41bb21c254 Set up client tests
These are definitely temporary tests, as testing the lcient API will become more difficult and require more infrastructure as real data starts entering the system.
2025-02-16 14:12:17 -05:00
182020e136 Create a typescript client library for the server 2025-02-14 09:53:08 -05:00
79af050f53 Make a sample auth endpoint 2025-02-14 09:26:04 -05:00
dca9c3c39e Set up automated testing 2025-02-13 22:58:04 -05:00
e9f89e1bdb Create a tiny server for testing the Fetch API 2025-02-13 22:41:17 -05:00
f6534d5d05 Switch to vite instead of typescript 2025-02-13 19:01:21 -05:00
25 changed files with 1094 additions and 446 deletions

738
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,24 +2,24 @@
resolver = "2"
members = [
"authdb",
# "bike-lights/bike",
"bike-lights/core",
"bike-lights/simulator",
"changeset",
"config",
"config-derive",
"coordinates",
"cyberpunk",
"cyber-slides",
"cyberpunk",
"cyberpunk-splash",
"dashboard",
"emseries",
"file-service",
"fitnesstrax/core",
"fitnesstrax/app",
"fitnesstrax/core",
"fluent-ergonomics",
"geo-types",
"gm-control-panel",
"gm-dash/server",
"hex-grid",
"icon-test",
"memorycache",
@ -32,5 +32,8 @@ members = [
"timezone-testing",
"tree",
"visions/server",
"gm-dash/server"
"visions/types",
"visions/ui",
"visions/yew-app",
# "bike-lights/bike",
]

View File

@ -25,6 +25,7 @@
pkgs.cargo-watch
pkgs.clang
pkgs.crate2nix
pkgs.trunk
pkgs.glib
pkgs.gst_all_1.gst-plugins-bad
pkgs.gst_all_1.gst-plugins-base

View File

@ -4,3 +4,12 @@ version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.8.1", features = ["macros"] }
visions-types = { path = "../types" }
serde = { version = "1.0.217", features = ["derive", "serde_derive"] }
tokio = { version = "1.43.0", features = ["full", "rt"] }
tower-http = { version = "0.6.2", features = ["cors"] }
typeshare = "1.0.4"
uuid = { version = "1.13.1", features = ["v4"] }
result-extended = { path = "../../result-extended" }
thiserror = "2.0.11"

View File

@ -1,3 +1,155 @@
fn main() {
println!("Hello, world!");
use std::future::Future;
use axum::{
http::{
header::{AUTHORIZATION, CONTENT_TYPE},
HeaderMap, Method, StatusCode,
},
routing::{get, post},
Json, Router,
};
use visions_types::{AccountStatus, AuthRequest, AuthResponse, SessionId, UserOverview};
use result_extended::{error, ok, ResultExt};
use thiserror::Error;
use tower_http::cors::{Any, CorsLayer};
#[axum::debug_handler]
async fn check_password(
request: Json<AuthRequest>,
) -> (StatusCode, Json<Option<AuthResponse>>) {
let Json(request) = request;
if request.username == "vakarian" && request.password == "aoeu" {
(
StatusCode::OK,
Json(Some(AuthResponse::Success("vakarian-session-id".into()))),
)
} else if request.username == "shephard" && request.password == "aoeu" {
(
StatusCode::OK,
Json(Some(AuthResponse::PasswordReset(
"shephard-session-id".into(),
))),
)
} else {
(StatusCode::UNAUTHORIZED, Json(None))
}
}
#[derive(Debug, Error)]
enum AppError {
#[error("no user authorized")]
Unauthorized,
#[error("bad request")]
BadRequest,
}
#[derive(Debug, Error)]
enum FatalError {
#[error("on unknown fatal error occurred")]
Unknown,
}
impl result_extended::FatalError for FatalError {}
fn parse_session_header(headers: HeaderMap) -> ResultExt<Option<SessionId>, AppError, FatalError> {
match headers.get("Authorization") {
Some(token) => {
match token
.to_str()
.unwrap()
.split(" ")
.collect::<Vec<&str>>()
.as_slice()
{
[_schema, token] => ok(Some(SessionId::from(*token))),
_ => error(AppError::BadRequest),
}
}
None => ok(None),
}
}
async fn auth_required<B, F, Fut>(headers: HeaderMap, f: F) -> (StatusCode, Json<Option<B>>)
where
F: Fn() -> Fut,
Fut: Future<Output = (StatusCode, B)>,
{
match parse_session_header(headers) {
ResultExt::Ok(Some(session_id)) => {
if session_id == "vakarian-session-id".into() {
let (code, result) = f().await;
(code, Json(Some(result)))
} else {
(StatusCode::UNAUTHORIZED, Json(None))
}
}
ResultExt::Ok(None) => (StatusCode::UNAUTHORIZED, Json(None)),
ResultExt::Err(AppError::Unauthorized) => (StatusCode::UNAUTHORIZED, Json(None)),
ResultExt::Err(AppError::BadRequest) => (StatusCode::BAD_REQUEST, Json(None)),
ResultExt::Fatal(err) => {
panic!("{}", err);
}
}
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route(
"/api/test/health",
get(|| async { (StatusCode::OK, Json(None::<String>)) }).layer(
CorsLayer::new()
.allow_methods([Method::GET])
.allow_origin(Any),
),
)
.route(
"/api/test/auth",
post(|req: Json<AuthRequest>| check_password(req)).layer(
CorsLayer::new()
.allow_methods([Method::POST])
.allow_headers([CONTENT_TYPE])
.allow_origin(Any),
),
)
.route(
"/api/test/list-users",
get(|headers: HeaderMap| {
auth_required(headers, || async {
(
StatusCode::OK,
Some(vec![
UserOverview {
id: "vakarian-id".into(),
name: "vakarian".to_owned(),
status: AccountStatus::Ok,
},
UserOverview {
id: "shephard-id".into(),
name: "shephard".to_owned(),
status: AccountStatus::PasswordReset(
"2050-01-01 00:00:00".to_owned(),
),
},
UserOverview {
id: "tali-id".into(),
name: "tali".to_owned(),
status: AccountStatus::Locked,
},
]),
)
})
})
.layer(
CorsLayer::new()
.allow_headers([AUTHORIZATION])
.allow_methods([Method::GET])
.allow_origin(Any),
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:8001")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}

2
visions/types/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
gen/
dist/

8
visions/types/Cargo.toml Normal file
View File

@ -0,0 +1,8 @@
[package]
name = "visions-types"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1.0.218", features = ["derive"] }
uuid = { version = "1.13.2", features = ["v4", "js"] }

View File

@ -1,8 +0,0 @@
version: '3'
tasks:
build:
cmds:
- npm install typescript
- typeshare --lang typescript --output-file visions.ts ../server/src
- npx tsc

View File

@ -1,14 +0,0 @@
{
"name": "visions-types",
"version": "0.0.1",
"description": "Shared data types for Visions",
"main": "visions.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"typescript": "^5.7.3"
}
}

81
visions/types/src/lib.rs Normal file
View File

@ -0,0 +1,81 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize, Serialize)]
#[serde(tag = "type", content = "content", rename_all = "kebab-case")]
pub enum AccountStatus {
Ok,
PasswordReset(String),
Locked,
}
#[derive(Deserialize, Serialize)]
pub struct AuthRequest {
pub username: String,
pub password: String,
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct SessionId(String);
impl SessionId {
pub fn new() -> Self {
Self(format!("{}", Uuid::new_v4().hyphenated()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for SessionId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<String> for SessionId {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct UserId(String);
impl UserId {
pub fn new() -> Self {
Self(format!("{}", Uuid::new_v4().hyphenated()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for UserId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<String> for UserId {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Deserialize, Serialize)]
pub struct UserOverview {
pub id: UserId,
pub name: String,
pub status: AccountStatus,
}
#[derive(Deserialize, Serialize)]
#[serde(tag = "type", content = "content", rename_all = "kebab-case")]
pub enum AuthResponse {
Success(SessionId),
PasswordReset(SessionId),
}

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["./visions.ts"]
}

23
visions/ui/.gitignore vendored
View File

@ -1,23 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

17
visions/ui/Cargo.toml Normal file
View File

@ -0,0 +1,17 @@
[package]
name = "visions-client"
version = "0.1.0"
edition = "2021"
[dependencies]
visions-types = { path = "../types" }
gloo-console = "0.3.0"
gloo-net = "0.6.0"
serde = { version = "1.0.217", features = ["derive"] }
serde-wasm-bindgen = "0.6.5"
serde_json = "1.0.138"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "0.4.50"
web-sys = "0.3.77"
yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] }

View File

@ -1,14 +0,0 @@
version: '3'
tasks:
dev:
cmds:
- cd ../visions-types && task build
- npm install
- npm run start
test:
cmds:
- cd ../visions-types && task build
- npm install
- npm run test

3
visions/ui/Trunk.toml Normal file
View File

@ -0,0 +1,3 @@
[[proxy]]
backend = "http://localhost:8001/api"
insecure = true

36
visions/ui/design.css Normal file
View File

@ -0,0 +1,36 @@
:root {
--spacing-s: 4px;
--spacing-m: 8px;
--shadow-shallow: 2px 2px 1px;
}
body {
background-color: hsl(0, 0%, 95%);
font-family: Ariel, sans-serif;
}
.card {
display: flex;
flex-direction: column;
align-items: space-between;
border: 1px solid black;
box-shadow: var(--shadow-shallow);
border-radius: var(--spacing-s);
padding: var(--spacing-m);
}
.card > h1 {
margin: 0px;
}
.card > * {
margin-top: var(--spacing-s);
margin-bottom: var(--spacing-s);
}
.login-form {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
}

9
visions/ui/index.html Normal file
View File

@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Visions Client Demo</title>
<link data-trunk rel="css" href="design.css" />
</head>
<body></body>
</html>

View File

@ -1,51 +0,0 @@
{
"name": "ui",
"version": "0.1.0",
"private": true,
"dependencies": {
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.119",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-router": "^5.1.20",
"@types/react-router-dom": "^5.3.3",
"classnames": "^2.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router": "^6.28.0",
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"react-use-websocket": "^4.11.1",
"typescript": "^4.9.5",
"visions-types": "../visions-types",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

44
visions/ui/src/client.rs Normal file
View File

@ -0,0 +1,44 @@
use std::future::Future;
use gloo_console::log;
use gloo_net::http::{Request, Response};
use visions_types::{AuthRequest, AuthResponse, SessionId, UserOverview};
pub enum ClientError {
Unauthorized,
Err(u16),
}
pub trait Client {
fn auth(&self, username: String, password: String) -> impl Future<Output = Result<AuthResponse, ClientError>>;
fn list_users(&self, session_id: SessionId) -> impl Future<Output = Result<Vec<UserOverview>, ClientError>>;
}
pub struct Connection;
impl Connection {
pub fn new() -> Self { Self }
}
impl Client for Connection {
async fn auth(&self, username: String, password: String) -> Result<AuthResponse, ClientError> {
log!("authenticating: ", &username, &password);
let response: Response = Request::post("/api/test/auth")
.header("Content-Type", "application/json")
.body(serde_wasm_bindgen::to_value(&serde_json::to_string(&AuthRequest{ username, password }).unwrap()).unwrap())
.unwrap()
.send()
.await
.unwrap();
if response.ok() {
Ok(serde_json::from_slice(&response.binary().await.unwrap()).unwrap())
} else {
Err(ClientError::Err(response.status()))
}
}
async fn list_users(&self, session_id: SessionId) -> Result<Vec<UserOverview>, ClientError> {
todo!()
}
}

128
visions/ui/src/main.rs Normal file
View File

@ -0,0 +1,128 @@
use std::rc::Rc;
use gloo_console::log;
use visions_types::AuthResponse;
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
mod client;
use client::*;
struct AuthInfo {
session_id: Option<String>,
}
impl Default for AuthInfo {
fn default() -> Self {
Self { session_id: None }
}
}
enum AuthAction {
Auth(String),
Unauth,
}
impl Reducible for AuthInfo {
type Action = AuthAction;
fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> {
// log!("reduce", action);
match action {
AuthAction::Auth(session_id) => Self {
session_id: Some(session_id),
}
.into(),
AuthAction::Unauth => Self { session_id: None }.into(),
}
}
}
#[derive(Properties, PartialEq)]
struct LoginProps {
on_login: Callback<(String, String)>,
}
#[function_component]
fn Login(LoginProps { on_login }: &LoginProps) -> Html {
let username = use_state(|| "".to_owned());
let password = use_state(|| "".to_owned());
let on_click = {
let on_login = on_login.clone();
let username = username.clone();
let password = password.clone();
Callback::from(move |_| on_login.emit((username.to_string(), password.to_string())))
};
let on_username_changed = {
let username = username.clone();
Callback::from(move |event: Event| {
let input = event
.target()
.and_then(|t| t.dyn_into::<HtmlInputElement>().ok());
if let Some(input) = input {
username.set(input.value());
}
})
};
let on_password_changed = {
let password = password.clone();
Callback::from(move |event: Event| {
let input = event
.target()
.and_then(|t| t.dyn_into::<HtmlInputElement>().ok());
if let Some(input) = input {
password.set(input.value());
}
})
};
html! {
<div class="login-form">
<div class="card">
<h1>{"Welcome to Visions VTT"}</h1>
<input type="text" name="username" placeholder="username" onchange={on_username_changed} />
<input type="password" name="password" placeholder="password" onchange={on_password_changed} />
<button onclick={on_click}>{"Login"}</button>
</div>
</div>
}
}
#[function_component]
fn App() -> Html {
let auth_info = use_reducer(AuthInfo::default);
let on_login = {
let auth_info = auth_info.clone();
Callback::from(move |(username, password)| {
let auth_info = auth_info.clone();
wasm_bindgen_futures::spawn_local(async move {
let client = Connection::new();
match client.auth(username, password).await {
Ok(AuthResponse::Success(session_id)) => {
auth_info.dispatch(AuthAction::Auth(session_id.as_str().to_owned()))
}
Ok(AuthResponse::PasswordReset(session_id)) => {
auth_info.dispatch(AuthAction::Auth(session_id.as_str().to_owned()))
}
Err(ClientError::Unauthorized) => todo!(),
Err(ClientError::Err(status)) => todo!(),
};
})
})
};
if auth_info.session_id.is_some() {
html! { <p>{ "this is just a thing" }</p> }
} else {
html! { <Login on_login={on_login.clone()} /> }
}
}
fn main() {
yew::Renderer::<App>::new().render();
}

View File

@ -1,27 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src",
"gen"
]
}

View File

@ -0,0 +1,11 @@
[package]
name = "yew-app"
version = "0.1.0"
edition = "2021"
[dependencies]
gloo-net = "0.6.0"
serde = { version = "1.0.217", features = ["derive"] }
wasm-bindgen-futures = "0.4.50"
yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] }

View File

@ -0,0 +1,3 @@
body {
background-color: hsl(0, 0%, 50%);
}

View File

@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head></head>
<body></body>
</html>

126
visions/yew-app/src/main.rs Normal file
View File

@ -0,0 +1,126 @@
use yew::prelude::*;
use serde::Deserialize;
use gloo_net::http::Request;
#[derive(Clone, PartialEq, Deserialize)]
struct Video {
id: usize,
title: String,
speaker: String,
url: String,
}
#[derive(Properties, PartialEq)]
struct VideosListProps {
videos: Vec<Video>,
on_click: Callback<Video>,
}
/*
fn videos() -> Vec<Video> {
vec![
Video {
id: 1,
title: "Building and breaking things".to_string(),
speaker: "John Doe".to_string(),
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
},
Video {
id: 2,
title: "The development process".to_string(),
speaker: "Jane Smith".to_string(),
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
},
Video {
id: 3,
title: "The Web 7.0".to_string(),
speaker: "Matt Miller".to_string(),
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
},
Video {
id: 4,
title: "Mouseless development".to_string(),
speaker: "Tom Jerry".to_string(),
url: "https://youtu.be/PsaFVLr8t4E".to_string(),
},
]
}
*/
#[function_component(VideosList)]
fn videos_list(VideosListProps { videos, on_click }: &VideosListProps) -> Html {
let on_click = on_click.clone();
videos
.iter()
.map(|video| {
let on_video_select = {
let on_click = on_click.clone();
let video = video.clone();
Callback::from(move |_| on_click.emit(video.clone()))
};
html! {
<p key={video.id} onclick={on_video_select}>{format!("{}: {}", video.speaker, video.title)}</p>
}
})
.collect()
}
#[derive(Properties, PartialEq)]
struct VideosDetailsProps {
video: Video,
}
#[function_component(VideoDetails)]
fn video_details(VideosDetailsProps { video }: &VideosDetailsProps) -> Html {
html! {
<div>
<h3>{video.title.clone()}</h3>
<img src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
</div>
}
}
#[function_component(App)]
fn app() -> Html {
let videos = use_state(|| vec![]);
{
let videos = videos.clone();
use_effect_with((), move |_| {
let videos = videos.clone();
wasm_bindgen_futures::spawn_local(async move {
let response = Request::get("/tutorial/data.json").send().await;
println!("response: {:?}", response);
let response = response.unwrap();
let fetched_videos: Vec<Video> = response.json().await.unwrap();
videos.set(fetched_videos);
});
|| ()
});
}
let selected_video = use_state(|| None);
let on_video_select = {
let selected_video = selected_video.clone();
Callback::from(move |video: Video| selected_video.set(Some(video)))
};
let details = selected_video.as_ref().map(|video| html! {
<VideoDetails video={video.clone()} />
});
html! {
<>
<h1>{ "RustConf Explorer" }</h1>
<div>
<h3>{"Videos to watch"}</h3>
<VideosList videos={(*videos).clone()} on_click={on_video_select.clone()} />
</div>
{ for details }
</>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}