Auth, also update todo list in README
This commit is contained in:
parent
55ee8de9a0
commit
ea438cb8b9
12 changed files with 1925 additions and 89 deletions
59
README.md
59
README.md
|
@ -2,10 +2,55 @@
|
|||
Yes, another rewrite was needed. Again.
|
||||
|
||||
## TODO
|
||||
- Server
|
||||
- Auth
|
||||
- Client
|
||||
- Overview
|
||||
- Graph
|
||||
- Calendar
|
||||
- Todo
|
||||
- EASY: Add option to pin/unpin notes
|
||||
- EASY: Improve mobile experience
|
||||
- Instead of dialogs, use drawers as these are easier to use on mobile.
|
||||
- Add more quick actions to the mobile bar, maybe context dependant.
|
||||
|
||||
- Overview page
|
||||
- Show some stats about the "vault"
|
||||
- Show upcoming tasks, once we have those
|
||||
- Show upcoming events, maybe a small calendar with a list under it?
|
||||
- Ctrl+k navigation/search menu
|
||||
- Support images in the text note type
|
||||
- Change styling in text note type
|
||||
- No header image by default.
|
||||
- Allow for image in header instead of gradient.
|
||||
- Make term links work correctly, instead of directing to an empty page
|
||||
- More text note type features
|
||||
- Code blocks
|
||||
- Lists
|
||||
- Latex
|
||||
- Modify todo item state by clicking on the indicator.
|
||||
- Better text formatting in text note type
|
||||
- Allow nested formatting, such as bold and italic, to be created in any order instead of only in outer-to-inner order.
|
||||
- Allow combinations of formatted text with other text types, such as bold links.
|
||||
- Don't apply formatting in certain parts/destroy formatting in some cases, such as inside the URL part of links, or terms.
|
||||
- Move notes between collections
|
||||
- What happens to properties set on notes when this is done? Do we try to migrate them and delete them otherwise, or do we keep them as is, but just ignore them unless moved back.
|
||||
- Note links
|
||||
- Graph page
|
||||
- Calendar page
|
||||
- STRETCH: Pull ICal subscriptions into calendar data.
|
||||
- STRETCH: Caldav/calendar support. Might be easier to write a small android wrapper app that is able to sync and pull/push the data into the metadata structure.
|
||||
- Todo page
|
||||
- Gather note todos into the metadata while editing
|
||||
- Gather todos from the metadata of each note, and render them in a nice list.
|
||||
- STRETCH: Allow editing of the todo state from this list. This is hard, since the client might not have the full document. To achieve this todo's might
|
||||
need to be fully stored in the metadata with a reference to them in the editor text. Might require a sub editor or something like that.
|
||||
- More collection views
|
||||
- Ability to view type per collection.
|
||||
- Maybe all options should be available and this should just be a default state?
|
||||
- Table like view
|
||||
- Allows for quick viewing editing of properties of all notes.
|
||||
- Detailed card view with certain properties shown on each note card
|
||||
- STRETCH: Contacts support. Same notes as caldav support.
|
||||
|
||||
|
||||
## Known bugs
|
||||
- Offline edits in the canvas (excalidraw) note type cause all state to be lost. This might be even more severe, not requiring offline edits.
|
||||
|
||||
|
||||
## Ideas
|
||||
- Maybe rename Todos to Tasks, since they also contain doing, done, deadline, and idea types. Plus it's just a nicer name.
|
||||
|
||||
|
|
14
backend/.env.example
Normal file
14
backend/.env.example
Normal file
|
@ -0,0 +1,14 @@
|
|||
# Obtained from your auth provider
|
||||
AUTH_ISSUER_URL=https://auth.example.com
|
||||
AUTH_CLIENT_ID=client_id
|
||||
AUTH_CLIENT_SECRET=client_secret
|
||||
|
||||
# Can be generated with `openssl rand -hex 64`
|
||||
AUTH_SECRET=someSecretValue
|
||||
|
||||
# Where the app data will be stored
|
||||
DATA_DIR=/data/docs
|
||||
FRONTEND_DIR=/app/frontend
|
||||
|
||||
# The URL of the website, used for redirect URL during auth
|
||||
APP_URL=https://example.com
|
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
|
@ -1,2 +1,3 @@
|
|||
target/
|
||||
data/
|
||||
.env
|
||||
|
|
1386
backend/Cargo.lock
generated
1386
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -4,8 +4,18 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
chrono = { version = "0.4.40", features = ["serde"] }
|
||||
cookie = "0.18.1"
|
||||
dotenvy = "0.15.7"
|
||||
futures-util = "0.3.31"
|
||||
hmac = "0.12.1"
|
||||
jwt = "0.16.0"
|
||||
openidconnect = "4.0.0"
|
||||
rand = "0.9.0"
|
||||
rocksdb = "0.22.0"
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
sha2 = "0.10.8"
|
||||
tokio = { version = "1.44.1", features = ["full"] }
|
||||
warp = "0.3.7"
|
||||
yrs = { version = "0.19.2", features = ["sync"] }
|
||||
|
|
|
@ -1,11 +1,37 @@
|
|||
use base64::prelude::*;
|
||||
use chrono::serde::ts_seconds;
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use cookie::time::{Duration, OffsetDateTime};
|
||||
use dotenvy::dotenv;
|
||||
use futures_util::StreamExt;
|
||||
use hmac::{Hmac, Mac};
|
||||
use jwt::{SignWithKey, VerifyWithKey};
|
||||
use openidconnect::{
|
||||
core::{
|
||||
CoreAuthDisplay, CoreAuthPrompt, CoreClient, CoreErrorResponseType, CoreGenderClaim,
|
||||
CoreIdTokenVerifier, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm,
|
||||
CoreProviderMetadata, CoreResponseType, CoreRevocableToken, CoreRevocationErrorResponse,
|
||||
CoreTokenIntrospectionResponse, CoreTokenResponse,
|
||||
},
|
||||
reqwest, AuthenticationFlow, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken,
|
||||
EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, Nonce,
|
||||
RedirectUrl, Scope, StandardErrorResponse,
|
||||
};
|
||||
use rand::{distr::Alphanumeric, rng, Rng};
|
||||
use rocksdb::TransactionDB;
|
||||
use sha2::Sha256;
|
||||
use std::{
|
||||
char,
|
||||
collections::HashMap,
|
||||
env,
|
||||
path::PathBuf,
|
||||
str::FromStr,
|
||||
sync::{Arc, Weak},
|
||||
};
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use warp::{
|
||||
http::{header, Response, StatusCode},
|
||||
reject::Reject,
|
||||
ws::{WebSocket, Ws},
|
||||
Filter, Rejection, Reply,
|
||||
};
|
||||
|
@ -17,10 +43,6 @@ use yrs_warp::{
|
|||
ws::{WarpSink, WarpStream},
|
||||
};
|
||||
|
||||
const DATA_DIR: &str = "data";
|
||||
const STATIC_DIR: &str = "../frontend/dist";
|
||||
const INDEX_HTML: &str = "../frontend/dist/index.html";
|
||||
|
||||
// Web socket flow:
|
||||
// -> Handle connection (ws_handler)
|
||||
// -> Check permissions (Not doing for new)
|
||||
|
@ -29,6 +51,49 @@ const INDEX_HTML: &str = "../frontend/dist/index.html";
|
|||
//
|
||||
//
|
||||
|
||||
type OpenidClient = Client<
|
||||
EmptyAdditionalClaims,
|
||||
CoreAuthDisplay,
|
||||
CoreGenderClaim,
|
||||
CoreJweContentEncryptionAlgorithm,
|
||||
CoreJsonWebKey,
|
||||
CoreAuthPrompt,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
CoreTokenResponse,
|
||||
CoreTokenIntrospectionResponse,
|
||||
CoreRevocableToken,
|
||||
CoreRevocationErrorResponse,
|
||||
EndpointSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointNotSet,
|
||||
EndpointMaybeSet,
|
||||
EndpointMaybeSet,
|
||||
>;
|
||||
|
||||
const COOKIE_AUTH_TOKEN: &str = "knotes_auth_token";
|
||||
const COOKIE_AUTH_NONCE: &str = "knotes_auth_nonce";
|
||||
const COOKIE_AUTH_CSRF: &str = "knotes_auth_csrf";
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct UserToken {
|
||||
display_name: String,
|
||||
email: String,
|
||||
|
||||
// Unused for now, might be checked later do allow some basic session management.
|
||||
// Issued At Time
|
||||
#[allow(dead_code)]
|
||||
#[serde(with = "ts_seconds")]
|
||||
iat: DateTime<Utc>,
|
||||
// JWT Id
|
||||
#[allow(dead_code)]
|
||||
jti: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct AuthError;
|
||||
impl Reject for AuthError {}
|
||||
|
||||
struct Connection {
|
||||
bcast: BroadcastGroup,
|
||||
// This is purely here to keep the connection to the DB alive while there is at least one
|
||||
|
@ -41,19 +106,32 @@ struct Server {
|
|||
// clients disconnect, but for now we don't bother.
|
||||
pub open_docs: RwLock<HashMap<String, Weak<Connection>>>,
|
||||
pub db: Arc<TransactionDB>,
|
||||
|
||||
pub http_client: reqwest::Client,
|
||||
pub openid_client: OpenidClient,
|
||||
|
||||
pub signing_key: Hmac<Sha256>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(db: TransactionDB) -> Self {
|
||||
pub fn new(
|
||||
db: TransactionDB,
|
||||
http_client: reqwest::Client,
|
||||
openid_client: OpenidClient,
|
||||
signing_key: Hmac<Sha256>,
|
||||
) -> Self {
|
||||
Self {
|
||||
open_docs: RwLock::default(),
|
||||
db: Arc::new(db),
|
||||
http_client,
|
||||
openid_client,
|
||||
signing_key,
|
||||
}
|
||||
}
|
||||
pub async fn get_or_create_doc(&self, name: String) -> Arc<Connection> {
|
||||
let open_docs = self.open_docs.read().await;
|
||||
match open_docs.get(&name).and_then(Weak::upgrade) {
|
||||
Some(group) => group.clone(),
|
||||
Some(connection) => connection.clone(),
|
||||
None => {
|
||||
drop(open_docs);
|
||||
let mut open_docs = self.open_docs.write().await;
|
||||
|
@ -97,41 +175,295 @@ impl Server {
|
|||
}
|
||||
}
|
||||
|
||||
async fn create_openid_client(
|
||||
http_client: &reqwest::Client,
|
||||
issuer_url: IssuerUrl,
|
||||
client_id: ClientId,
|
||||
client_secret: ClientSecret,
|
||||
redirect_url: RedirectUrl,
|
||||
) -> OpenidClient {
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, http_client)
|
||||
.await
|
||||
.expect("Failed to discover OpenID Connect provider metadata");
|
||||
|
||||
CoreClient::from_provider_metadata(provider_metadata, client_id, Some(client_secret))
|
||||
.set_redirect_uri(redirect_url)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let db = TransactionDB::open_default(DATA_DIR).expect("Failed to open DB");
|
||||
let server = Arc::new(Server::new(db));
|
||||
dotenv().expect("Failed to load .env file");
|
||||
|
||||
let ws = warp::path("sync")
|
||||
let data_dir = env::var("DATA_DIR").expect("DATA_DIR not set");
|
||||
let data_dir = PathBuf::from_str(&data_dir).expect("DATA_DIR is not a valid path");
|
||||
let frontend_dir = env::var("FRONTEND_DIR").expect("FRONTEND_DIR not set");
|
||||
let frontend_dir = PathBuf::from_str(&frontend_dir).expect("FRONTEND_DIR is not a valid path");
|
||||
let index_file = frontend_dir.join("index.html");
|
||||
|
||||
let signing_key = env::var("AUTH_SECRET").expect("AUTH_SECRET not set");
|
||||
let signing_key =
|
||||
Hmac::new_from_slice(signing_key.as_bytes()).expect("AUTH_SECRET is not a valid HMAC key");
|
||||
|
||||
let app_url = env::var("APP_URL").expect("APP_URL not set");
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
// Following redirects opens the client up to SSRF vulnerabilities.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let openid_client = create_openid_client(
|
||||
&http_client,
|
||||
IssuerUrl::new(env::var("AUTH_ISSUER_URL").expect("AUTH_ISSUER_URL not set"))
|
||||
.expect("Issuer URL invalid"),
|
||||
ClientId::new(env::var("AUTH_CLIENT_ID").expect("AUTH_CLIENT_ID not set")),
|
||||
ClientSecret::new(env::var("AUTH_CLIENT_SECRET").expect("AUTH_CLIENT_SECRET not set")),
|
||||
RedirectUrl::new(format!("{}/api/auth/callback", app_url))
|
||||
.expect("Redirect URL is invalid (app url?)"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let db = TransactionDB::open_default(&data_dir).expect("Failed to open DB");
|
||||
let server = Arc::new(Server::new(db, http_client, openid_client, signing_key));
|
||||
|
||||
let ws = {
|
||||
let server = server.clone();
|
||||
warp::path("sync")
|
||||
.and(warp::path::param())
|
||||
.and(warp::ws())
|
||||
.and(warp::any().map(move || server.clone()))
|
||||
.and_then(ws_handler);
|
||||
.and(warp::cookie(COOKIE_AUTH_TOKEN))
|
||||
.and_then(ws_handler)
|
||||
};
|
||||
|
||||
let static_files = warp::fs::dir(STATIC_DIR);
|
||||
let api_routes = {
|
||||
let login_route = {
|
||||
let server = server.clone();
|
||||
warp::path!("api" / "auth" / "login")
|
||||
.map(move || server.clone())
|
||||
.and(warp::query())
|
||||
.and_then(handle_auth_login)
|
||||
};
|
||||
|
||||
let index = warp::fs::file(INDEX_HTML);
|
||||
let callback_route = {
|
||||
let server = server.clone();
|
||||
warp::path!("api" / "auth" / "callback")
|
||||
.map(move || server.clone())
|
||||
.and(warp::cookie(COOKIE_AUTH_NONCE))
|
||||
.and(warp::cookie(COOKIE_AUTH_CSRF))
|
||||
.and(warp::query())
|
||||
.and_then(handle_auth_callback)
|
||||
};
|
||||
|
||||
let routes = ws.or(static_files).or(index);
|
||||
login_route.or(callback_route)
|
||||
};
|
||||
|
||||
let frontend_files = warp::fs::dir(frontend_dir);
|
||||
|
||||
let index = warp::fs::file(index_file);
|
||||
|
||||
let routes = api_routes.or(ws).or(frontend_files).or(index);
|
||||
|
||||
warp::serve(routes).run(([0, 0, 0, 0], 9000)).await;
|
||||
}
|
||||
|
||||
async fn ws_handler(name: String, ws: Ws, server: Arc<Server>) -> Result<impl Reply, Rejection> {
|
||||
// TODO: Check permissions before upgrading
|
||||
async fn ws_handler(
|
||||
name: String,
|
||||
ws: Ws,
|
||||
server: Arc<Server>,
|
||||
token: String,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let token: UserToken = token
|
||||
.verify_with_key(&server.signing_key)
|
||||
.map_err(|_| warp::reject())?;
|
||||
|
||||
println!("ws_handler: {}", name);
|
||||
let connection = server.get_or_create_doc(name).await;
|
||||
Ok(ws.on_upgrade(move |socket| peer(socket, connection)))
|
||||
println!(
|
||||
"[INFO] User {} <{}> connected to document {}",
|
||||
token.display_name, token.email, name
|
||||
);
|
||||
let doc_id = format!("{}/{}", token.email, name);
|
||||
let connection = server.get_or_create_doc(doc_id).await;
|
||||
Ok(ws.on_upgrade(move |socket| peer(token, name, socket, connection)))
|
||||
}
|
||||
|
||||
async fn peer(ws: WebSocket, connection: Arc<Connection>) {
|
||||
async fn peer(token: UserToken, doc_name: String, ws: WebSocket, connection: Arc<Connection>) {
|
||||
let (sink, stream) = ws.split();
|
||||
let sink = Arc::new(Mutex::new(WarpSink::from(sink)));
|
||||
let stream = WarpStream::from(stream);
|
||||
let sub = connection.bcast.subscribe(sink, stream);
|
||||
match sub.completed().await {
|
||||
Ok(_) => println!("broadcasting for channel finished successfully"),
|
||||
Err(e) => eprintln!("broadcasting for channel finished abruptly: {}", e),
|
||||
}
|
||||
let _ = sub.completed().await;
|
||||
println!(
|
||||
"[INFO] User {} <{}> disconnected from document {}",
|
||||
token.display_name, token.email, doc_name
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
struct AuthState {
|
||||
redirect_url: Option<String>,
|
||||
csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AuthLoginParams {
|
||||
redirect_url: Option<String>,
|
||||
}
|
||||
|
||||
async fn handle_auth_login(
|
||||
server: Arc<Server>,
|
||||
params: AuthLoginParams,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let csrf_token = CsrfToken::new_random();
|
||||
let state = AuthState {
|
||||
redirect_url: params.redirect_url,
|
||||
csrf_token: csrf_token.clone().into_secret(),
|
||||
};
|
||||
let state = CsrfToken::new(
|
||||
BASE64_URL_SAFE.encode(
|
||||
state
|
||||
.sign_with_key(&server.signing_key)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?,
|
||||
),
|
||||
);
|
||||
// From what I understand I don't need the csrf_token
|
||||
let (auth_url, _csrf_token, nonce) = server
|
||||
.openid_client
|
||||
.authorize_url(
|
||||
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
|
||||
|| state,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.add_scope(Scope::new("openid".to_string()))
|
||||
.add_scope(Scope::new("profile".to_string()))
|
||||
.add_scope(Scope::new("email".to_string()))
|
||||
.url();
|
||||
|
||||
Ok(Response::builder()
|
||||
.header(
|
||||
header::SET_COOKIE,
|
||||
cookie::Cookie::build((COOKIE_AUTH_NONCE, nonce.secret()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.build()
|
||||
.to_string(),
|
||||
)
|
||||
.header(
|
||||
header::SET_COOKIE,
|
||||
cookie::Cookie::build((COOKIE_AUTH_CSRF, csrf_token.secret()))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.build()
|
||||
.to_string(),
|
||||
)
|
||||
.header(header::LOCATION, auth_url.as_str())
|
||||
.status(StatusCode::FOUND)
|
||||
.body("Login")
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct AuthCallbackParams {
|
||||
code: String,
|
||||
state: String,
|
||||
}
|
||||
|
||||
async fn handle_auth_callback(
|
||||
server: Arc<Server>,
|
||||
nonce: String,
|
||||
csrf_token: String,
|
||||
params: AuthCallbackParams,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let code = AuthorizationCode::new(params.code);
|
||||
let nonce = Nonce::new(nonce);
|
||||
let state: AuthState = String::from_utf8(
|
||||
BASE64_URL_SAFE
|
||||
.decode(params.state)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?,
|
||||
)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?
|
||||
.verify_with_key(&server.signing_key)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?;
|
||||
|
||||
if state.csrf_token != csrf_token {
|
||||
return Err(warp::reject::custom(AuthError));
|
||||
}
|
||||
|
||||
let token_response = server
|
||||
.openid_client
|
||||
.exchange_code(code)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?
|
||||
.request_async(&server.http_client)
|
||||
.await
|
||||
.map_err(|_| warp::reject::custom(AuthError))?;
|
||||
|
||||
let id_token_verifier: CoreIdTokenVerifier = server.openid_client.id_token_verifier();
|
||||
let id_token_claims = token_response
|
||||
.extra_fields()
|
||||
.id_token()
|
||||
.ok_or(warp::reject::custom(AuthError))?
|
||||
.claims(&id_token_verifier, &nonce)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?;
|
||||
|
||||
let token_id: String = rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(32)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
|
||||
let display_name = id_token_claims
|
||||
.given_name()
|
||||
.ok_or(warp::reject::custom(AuthError))?
|
||||
.get(None)
|
||||
.ok_or(warp::reject::custom(AuthError))?
|
||||
.to_string();
|
||||
let email = id_token_claims
|
||||
.email()
|
||||
.ok_or(warp::reject::custom(AuthError))?
|
||||
.to_string();
|
||||
|
||||
println!("[INFO] Authenticated: {} <{}>", display_name, email);
|
||||
|
||||
let user_token = UserToken {
|
||||
display_name,
|
||||
email,
|
||||
iat: Utc::now(),
|
||||
jti: token_id,
|
||||
};
|
||||
let user_token = user_token
|
||||
.sign_with_key(&server.signing_key)
|
||||
.map_err(|_| warp::reject::custom(AuthError))?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.header(
|
||||
header::SET_COOKIE,
|
||||
cookie::Cookie::build((COOKIE_AUTH_NONCE, ""))
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.build()
|
||||
.to_string(),
|
||||
)
|
||||
.header(
|
||||
header::SET_COOKIE,
|
||||
cookie::Cookie::build((COOKIE_AUTH_CSRF, ""))
|
||||
.expires(OffsetDateTime::UNIX_EPOCH)
|
||||
.path("/")
|
||||
.http_only(true)
|
||||
.build()
|
||||
.to_string(),
|
||||
)
|
||||
.header(
|
||||
header::SET_COOKIE,
|
||||
cookie::Cookie::build((COOKIE_AUTH_TOKEN, user_token))
|
||||
.expires(OffsetDateTime::now_utc() + Duration::days(365))
|
||||
.path("/")
|
||||
.build()
|
||||
.to_string(),
|
||||
)
|
||||
.header(
|
||||
header::LOCATION,
|
||||
state.redirect_url.unwrap_or("/app".to_string()),
|
||||
)
|
||||
.status(StatusCode::FOUND)
|
||||
.body("")
|
||||
.unwrap())
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite --port 3000",
|
||||
"start": "vite --port 9000",
|
||||
"build": "vite build && tsc",
|
||||
"serve": "vite preview",
|
||||
"test": "vitest run"
|
||||
|
@ -33,6 +33,7 @@
|
|||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lexical": "^0.25.0",
|
||||
"lucide-react": "^0.483.0",
|
||||
"react": "^19.0.0",
|
||||
|
@ -54,6 +55,7 @@
|
|||
"@testing-library/react": "^16.2.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"serwist": "^9.0.12",
|
||||
|
|
17
frontend/pnpm-lock.yaml
generated
17
frontend/pnpm-lock.yaml
generated
|
@ -80,6 +80,9 @@ importers:
|
|||
cmdk:
|
||||
specifier: 1.0.0
|
||||
version: 1.0.0(@types/react-dom@19.0.4(@types/react@19.0.11))(@types/react@19.0.11)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
js-cookie:
|
||||
specifier: ^3.0.5
|
||||
version: 3.0.5
|
||||
lexical:
|
||||
specifier: ^0.25.0
|
||||
version: 0.25.0
|
||||
|
@ -132,6 +135,9 @@ importers:
|
|||
'@testing-library/react':
|
||||
specifier: ^16.2.0
|
||||
version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.4(@types/react@19.0.11))(@types/react@19.0.11)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@types/js-cookie':
|
||||
specifier: ^3.0.6
|
||||
version: 3.0.6
|
||||
'@types/react':
|
||||
specifier: ^19.0.8
|
||||
version: 19.0.11
|
||||
|
@ -1557,6 +1563,9 @@ packages:
|
|||
'@types/estree@1.0.6':
|
||||
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
|
||||
|
||||
'@types/js-cookie@3.0.6':
|
||||
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||
|
||||
'@types/mdast@3.0.15':
|
||||
resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==}
|
||||
|
||||
|
@ -2283,6 +2292,10 @@ packages:
|
|||
react:
|
||||
optional: true
|
||||
|
||||
js-cookie@3.0.5:
|
||||
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
|
@ -4706,6 +4719,8 @@ snapshots:
|
|||
|
||||
'@types/estree@1.0.6': {}
|
||||
|
||||
'@types/js-cookie@3.0.6': {}
|
||||
|
||||
'@types/mdast@3.0.15':
|
||||
dependencies:
|
||||
'@types/unist': 2.0.11
|
||||
|
@ -5454,6 +5469,8 @@ snapshots:
|
|||
'@types/react': 19.0.11
|
||||
react: 19.0.0
|
||||
|
||||
js-cookie@3.0.5: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsdom@26.0.0:
|
||||
|
|
|
@ -14,6 +14,7 @@ import { UserAvatar } from "~/components/user/user_avatar";
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { useTheme } from "~/hooks/use-theme";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
import { useAuth } from "~/hooks/use-auth";
|
||||
|
||||
export function AppSidebar() {
|
||||
const collections = useCollections();
|
||||
|
@ -117,8 +118,13 @@ function CollectionButton(props: CollectionButtonProps) {
|
|||
params={{ id: props.id }}
|
||||
className="w-full flex items-center gap-2 group/item"
|
||||
>
|
||||
<div className="flex items-center justify-center h-4 w-4">
|
||||
{icon && <Icon path={icon.path} size={0.75} color={color.base} />}
|
||||
<div className="flex items-center justify-center h-4 w-4 flex-shrink-0">
|
||||
{icon
|
||||
? <Icon path={icon.path} size={0.75} color={color.base} />
|
||||
: <div className="h-4 w-4 rounded"
|
||||
style={{ backgroundColor: color.base }}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<span
|
||||
className="group-hover/item:text-[var(--hover-color)]"
|
||||
|
@ -172,26 +178,14 @@ function InboxButton() {
|
|||
function NavUser() {
|
||||
const isMobile = useIsMobile();
|
||||
const [theme, setTheme] = useTheme();
|
||||
// const { data: session } = useSession();
|
||||
// const user = session?.user;
|
||||
const { data: session, signOut } = useAuth();
|
||||
const user = {
|
||||
name: "Kalle Struik",
|
||||
email: "kalle@kallestruik.nl"
|
||||
name: session?.display_name,
|
||||
email: session?.email,
|
||||
};
|
||||
|
||||
const email = user?.email ?? undefined;
|
||||
const name = user?.name ?? undefined
|
||||
|
||||
function handleSignOut() {
|
||||
// // End the session
|
||||
// signOut();
|
||||
// // Clear all locally stored data
|
||||
// localStorage.clear();
|
||||
// indexedDB.databases().then((dbs) => {
|
||||
// dbs.forEach((db) => {
|
||||
// db.name && indexedDB.deleteDatabase(db.name);
|
||||
// });
|
||||
// });
|
||||
signOut();
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -205,8 +199,8 @@ function NavUser() {
|
|||
>
|
||||
<UserAvatar user={user} className="h-8 w-8 rounded-lg" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs">{email}</span>
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
|
@ -221,8 +215,8 @@ function NavUser() {
|
|||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<UserAvatar user={user} className="h-8 w-8 rounded-lg" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs">{email}</span>
|
||||
<span className="truncate font-semibold">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
|
76
frontend/src/hooks/use-auth.tsx
Normal file
76
frontend/src/hooks/use-auth.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import Cookies from "js-cookie";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useRedraw } from "./use-redraw";
|
||||
|
||||
type AuthContextType = {
|
||||
jwt?: {
|
||||
display_name: string;
|
||||
email: string;
|
||||
|
||||
iat: number,
|
||||
jti: string,
|
||||
},
|
||||
signOut: () => void;
|
||||
};
|
||||
|
||||
const AUTH_TOKEN_COOKIE_NAME = "knotes_auth_token";
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider(props: { children: React.ReactNode }) {
|
||||
const redraw = useRedraw();
|
||||
const navigate = useNavigate();
|
||||
const authToken = Cookies.get(AUTH_TOKEN_COOKIE_NAME);
|
||||
const jwt: AuthContextType["jwt"] = authToken ? parseJwt(authToken) : null;
|
||||
|
||||
function signOut() {
|
||||
// Remove the auth token
|
||||
Cookies.remove(AUTH_TOKEN_COOKIE_NAME);
|
||||
|
||||
// Redirect to the landing page
|
||||
navigate({ to: "/" }).then(() => {
|
||||
// Force refresh the auth token from the cookie
|
||||
redraw();
|
||||
|
||||
// After navigation completes, delete all locally stored data.
|
||||
// We do this because otherwise the providers might still be mounted
|
||||
// and might resync the data before they are unmounted.
|
||||
indexedDB.databases().then((dbs) => {
|
||||
dbs.forEach((db) => {
|
||||
db.name && indexedDB.deleteDatabase(db.name);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ jwt, signOut }}>
|
||||
{props.children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const userData = useContext(AuthContext);
|
||||
|
||||
if (!userData) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
|
||||
return {
|
||||
isAuthenticated: !!userData?.jwt,
|
||||
data: userData.jwt,
|
||||
signOut: userData.signOut,
|
||||
};
|
||||
}
|
||||
|
||||
function parseJwt(token: string) {
|
||||
var base64Url = token.split('.')[1];
|
||||
var base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
var jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function(c) {
|
||||
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
||||
}).join(''));
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
|
@ -2,6 +2,7 @@ import { createRootRoute, Outlet } from "@tanstack/react-router";
|
|||
// import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { useEffect } from "react";
|
||||
import { getSerwist } from "virtual:serwist";
|
||||
import { AuthProvider } from "~/hooks/use-auth";
|
||||
import { ThemeProvider } from "~/hooks/use-theme";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
|
@ -26,8 +27,10 @@ function RootLayout() {
|
|||
}, []);
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Outlet />
|
||||
{/*<TanStackRouterDevtools />*/}
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { AppSidebar } from "~/components/app_sidebar";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { SidebarInset, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
|
||||
import { useAuth } from "~/hooks/use-auth";
|
||||
import { MetadataProvider } from "~/hooks/use-metadata";
|
||||
import { useIsMobile } from "~/hooks/use-mobile";
|
||||
|
||||
|
@ -11,6 +13,22 @@ export const Route = createFileRoute("/app")({
|
|||
});
|
||||
|
||||
export function AppLayout() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to the login page using the browser navigator,
|
||||
// since we are leaving the SPA for auth here.
|
||||
document.location.href = `/api/auth/login?redirect_url=${encodeURIComponent(document.location.href)}`;
|
||||
}, [isAuthenticated]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MetadataProvider>
|
||||
|
|
Loading…
Add table
Reference in a new issue