feat: add app-level authentication foundation
This commit is contained in:
@@ -6,7 +6,9 @@ rust-version.workspace = true
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
argon2.workspace = true
|
||||
axum.workspace = true
|
||||
axum-extra.workspace = true
|
||||
base64.workspace = true
|
||||
crank-core = { path = "../../crates/crank-core" }
|
||||
crank-mapping = { path = "../../crates/crank-mapping" }
|
||||
@@ -14,6 +16,7 @@ crank-proto = { path = "../../crates/crank-proto" }
|
||||
crank-registry = { path = "../../crates/crank-registry" }
|
||||
crank-runtime = { path = "../../crates/crank-runtime" }
|
||||
crank-schema = { path = "../../crates/crank-schema" }
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
@@ -28,4 +31,5 @@ uuid.workspace = true
|
||||
[dev-dependencies]
|
||||
crank-adapter-grpc = { path = "../../crates/crank-adapter-grpc", features = ["test-support"] }
|
||||
reqwest.workspace = true
|
||||
serial_test = "3"
|
||||
sqlx.workspace = true
|
||||
|
||||
+198
-55
@@ -1,9 +1,10 @@
|
||||
use axum::{
|
||||
Router,
|
||||
Router, middleware,
|
||||
routing::{delete, get, post},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
auth::{require_session, require_workspace_session},
|
||||
routes::{
|
||||
access::{
|
||||
create_invitation, create_platform_api_key, delete_invitation, delete_platform_api_key,
|
||||
@@ -13,6 +14,7 @@ use crate::{
|
||||
create_agent, delete_agent, get_agent, get_agent_version, list_agents, publish_agent,
|
||||
save_agent_bindings, update_agent,
|
||||
},
|
||||
auth::{get_session, login, logout},
|
||||
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
|
||||
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
|
||||
operations::{
|
||||
@@ -115,16 +117,35 @@ pub fn build_app(state: AppState) -> Router {
|
||||
.route("/usage/operations/{operation_id}", get(get_operation_usage))
|
||||
.route("/usage/agents/{agent_id}", get(get_agent_usage));
|
||||
|
||||
let admin_router = Router::new()
|
||||
let workspace_root_router = Router::new()
|
||||
.route("/workspaces", get(list_workspaces).post(create_workspace))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_session,
|
||||
));
|
||||
|
||||
let workspace_scoped_router = Router::new()
|
||||
.route(
|
||||
"/workspaces/{workspace_id}",
|
||||
get(get_workspace).patch(update_workspace),
|
||||
)
|
||||
.nest("/workspaces/{workspace_id}", workspace_router);
|
||||
.nest("/workspaces/{workspace_id}", workspace_router)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_workspace_session,
|
||||
));
|
||||
|
||||
let admin_router = workspace_root_router.merge(workspace_scoped_router);
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(crate::routes::health))
|
||||
.nest(
|
||||
"/api/auth",
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/session", get(get_session)),
|
||||
)
|
||||
.nest("/api/admin", admin_router)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -135,7 +156,7 @@ use crate::routes::operations::import_operation;
|
||||
mod tests {
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
env,
|
||||
env, fmt,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
@@ -143,30 +164,71 @@ mod tests {
|
||||
use crank_adapter_grpc::test_support as grpc_test_support;
|
||||
use crank_core::{
|
||||
DescriptorId, ExecutionConfig, GraphqlOperationType, GraphqlTarget, GrpcTarget, HttpMethod,
|
||||
Protocol, RestTarget, Target, ToolDescription,
|
||||
MembershipRole, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
|
||||
};
|
||||
use crank_mapping::{MappingRule, MappingSet};
|
||||
use crank_registry::PostgresRegistry;
|
||||
use crank_schema::{Schema, SchemaKind};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::{Executor, postgres::PgPoolOptions};
|
||||
use serial_test::serial;
|
||||
use sqlx::{Connection, Executor, PgConnection};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig, hash_password},
|
||||
service::{AdminService, OperationPayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
const DEFAULT_WORKSPACE_ID: &str = "ws_default";
|
||||
const TEST_AUTH_EMAIL: &str = "owner@crank.local";
|
||||
const TEST_AUTH_PASSWORD: &str = "test-password";
|
||||
const TEST_PASSWORD_PEPPER: &str = "test-password-pepper";
|
||||
const TEST_SESSION_SECRET: &str = "test-session-secret";
|
||||
|
||||
#[tokio::test]
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
shutdown: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
let shutdown = self.shutdown.take();
|
||||
let handle = self.handle.take();
|
||||
|
||||
tokio::task::block_in_place(|| {
|
||||
if let Some(shutdown) = shutdown {
|
||||
let _ = shutdown.send(());
|
||||
}
|
||||
if let Some(handle) = handle {
|
||||
let _ = tokio::runtime::Handle::current().block_on(handle);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TestServer {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.base_url)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for TestServer {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_publishes_and_tests_rest_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("lifecycle");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -227,13 +289,14 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn updates_archives_and_deletes_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("operation_mutations");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -376,13 +439,14 @@ mod tests {
|
||||
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_binds_and_publishes_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_lifecycle");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -392,10 +456,8 @@ mod tests {
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation = assert_success_json(operation).await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
@@ -456,13 +518,14 @@ mod tests {
|
||||
assert_eq!(published["published_version"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn updates_lists_and_deletes_agent() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("agent_mutations");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let operation = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -472,10 +535,8 @@ mod tests {
|
||||
))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<Value>()
|
||||
.await
|
||||
.unwrap();
|
||||
let operation = assert_success_json(operation).await;
|
||||
let operation_id = operation["operation_id"].as_str().unwrap().to_owned();
|
||||
|
||||
client
|
||||
@@ -587,12 +648,13 @@ mod tests {
|
||||
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_platform_access_resources() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("platform_access");
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let members = client
|
||||
.get(format!("{base_url}/members"))
|
||||
@@ -694,13 +756,14 @@ mod tests {
|
||||
assert_eq!(delete_key_status, reqwest::StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn exposes_logs_and_usage_from_real_test_runs() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("observability");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -774,13 +837,14 @@ mod tests {
|
||||
assert_eq!(operation_usage["rollup"]["calls_total"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_publishes_and_tests_graphql_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("graphql");
|
||||
let upstream_base_url = spawn_graphql_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -827,13 +891,14 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn uploads_descriptor_set_and_lists_grpc_services() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("grpc_descriptor");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -879,13 +944,14 @@ mod tests {
|
||||
assert_eq!(services["services"][0]["methods"][0]["kind"], "unary");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn creates_publishes_and_tests_grpc_operation() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("grpc_runtime");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -926,13 +992,14 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["message"], "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn manages_auth_profiles_and_yaml_upsert() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("yaml");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let auth_profile = client
|
||||
.post(format!("{base_url}/auth-profiles"))
|
||||
@@ -989,13 +1056,14 @@ mod tests {
|
||||
assert_eq!(imported["import_mode"], "upsert");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn roundtrips_graphql_operation_through_yaml_upsert() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("yaml_graphql");
|
||||
let upstream_base_url = spawn_graphql_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -1047,13 +1115,14 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["id"], "lead_123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn roundtrips_grpc_operation_through_yaml_upsert() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("yaml_grpc");
|
||||
let server_addr = grpc_test_support::spawn_unary_echo_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -1102,13 +1171,14 @@ mod tests {
|
||||
assert_eq!(test_run["response_preview"]["message"], "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial]
|
||||
async fn uploads_samples_and_generates_draft() {
|
||||
let registry = test_registry().await;
|
||||
let storage_root = test_storage_root("draft");
|
||||
let upstream_base_url = spawn_upstream_server().await;
|
||||
let base_url = spawn_admin_api(build_test_app(registry, storage_root)).await;
|
||||
let client = reqwest::Client::new();
|
||||
let client = authorized_client(&base_url).await;
|
||||
|
||||
let created = client
|
||||
.post(format!("{base_url}/operations"))
|
||||
@@ -1168,7 +1238,7 @@ mod tests {
|
||||
|
||||
fn build_test_app(registry: PostgresRegistry, storage_root: std::path::PathBuf) -> Router {
|
||||
build_app(AppState {
|
||||
service: AdminService::new(registry, storage_root),
|
||||
service: AdminService::new(registry, storage_root, test_auth_settings()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1196,18 +1266,66 @@ mod tests {
|
||||
format!("http://{}", address)
|
||||
}
|
||||
|
||||
async fn spawn_admin_api(app: Router) -> String {
|
||||
async fn spawn_admin_api(app: Router) -> TestServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
let handle = tokio::spawn(async move {
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
format!(
|
||||
"http://{}/api/admin/workspaces/{}",
|
||||
address, DEFAULT_WORKSPACE_ID
|
||||
)
|
||||
TestServer {
|
||||
base_url: format!(
|
||||
"http://{}/api/admin/workspaces/{}",
|
||||
address, DEFAULT_WORKSPACE_ID
|
||||
),
|
||||
shutdown: Some(shutdown_tx),
|
||||
handle: Some(handle),
|
||||
}
|
||||
}
|
||||
|
||||
async fn authorized_client(workspace_base_url: impl AsRef<str>) -> reqwest::Client {
|
||||
let root_url = workspace_base_url
|
||||
.as_ref()
|
||||
.split("/api/admin/workspaces/")
|
||||
.next()
|
||||
.unwrap();
|
||||
let client = reqwest::Client::builder()
|
||||
.cookie_store(true)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
.post(format!("{root_url}/api/auth/login"))
|
||||
.json(&json!({
|
||||
"email": TEST_AUTH_EMAIL,
|
||||
"password": TEST_AUTH_PASSWORD,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap();
|
||||
|
||||
client
|
||||
}
|
||||
|
||||
async fn assert_success_json(response: reqwest::Response) -> Value {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap();
|
||||
|
||||
assert!(
|
||||
status.is_success(),
|
||||
"request failed with status {status}: {body}"
|
||||
);
|
||||
|
||||
serde_json::from_str(&body).unwrap()
|
||||
}
|
||||
|
||||
async fn create_lead(Json(payload): Json<Value>) -> Json<Value> {
|
||||
@@ -1239,11 +1357,7 @@ mod tests {
|
||||
async fn test_registry() -> PostgresRegistry {
|
||||
let database_url = env::var("TEST_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "postgres://crank:crank@127.0.0.1:5432/crank".to_owned());
|
||||
let admin_pool = PgPoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut admin_connection = PgConnection::connect(&database_url).await.unwrap();
|
||||
let schema = format!(
|
||||
"test_admin_api_{}_{}",
|
||||
std::process::id(),
|
||||
@@ -1253,14 +1367,29 @@ mod tests {
|
||||
.as_nanos()
|
||||
);
|
||||
|
||||
admin_pool
|
||||
admin_connection
|
||||
.execute(sqlx::query(&format!("create schema {schema}")))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}"))
|
||||
let registry =
|
||||
PostgresRegistry::connect(&format!("{database_url}?options=-csearch_path%3D{schema}"))
|
||||
.await
|
||||
.unwrap();
|
||||
let password_hash = hash_password(TEST_AUTH_PASSWORD, TEST_PASSWORD_PEPPER).unwrap();
|
||||
let user_id = registry
|
||||
.upsert_bootstrap_user(TEST_AUTH_EMAIL, "Test Owner", &password_hash)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
registry
|
||||
.ensure_membership(
|
||||
&WorkspaceId::new(DEFAULT_WORKSPACE_ID),
|
||||
&user_id,
|
||||
MembershipRole::Owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
registry
|
||||
}
|
||||
|
||||
fn test_storage_root(name: &str) -> std::path::PathBuf {
|
||||
@@ -1274,6 +1403,20 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
fn test_auth_settings() -> AuthSettings {
|
||||
AuthSettings {
|
||||
session_secret: TEST_SESSION_SECRET.to_owned(),
|
||||
password_pepper: TEST_PASSWORD_PEPPER.to_owned(),
|
||||
session_ttl_hours: 24,
|
||||
cookie_secure: false,
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: TEST_AUTH_EMAIL.to_owned(),
|
||||
password: TEST_AUTH_PASSWORD.to_owned(),
|
||||
display_name: "Test Owner".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn test_operation_payload(base_url: &str, name: &str) -> OperationPayload {
|
||||
OperationPayload {
|
||||
name: name.to_owned(),
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
use argon2::{
|
||||
Argon2,
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng},
|
||||
};
|
||||
use axum::{
|
||||
extract::{OriginalUri, Request, State},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use crank_core::{User, UserSessionId, WorkspaceId};
|
||||
use crank_registry::WorkspaceMembershipRecord;
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
use crate::{error::ApiError, state::AppState};
|
||||
|
||||
pub const SESSION_COOKIE_NAME: &str = "crank_session";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BootstrapAdminConfig {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AuthSettings {
|
||||
pub session_secret: String,
|
||||
pub password_pepper: String,
|
||||
pub session_ttl_hours: i64,
|
||||
pub cookie_secure: bool,
|
||||
pub bootstrap_admin: BootstrapAdminConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AuthenticatedSession {
|
||||
pub user: User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionCookie {
|
||||
pub session_id: UserSessionId,
|
||||
pub value: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let password = format!("{password}{pepper}");
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|error| ApiError::internal(format!("failed to hash password: {error}")))
|
||||
}
|
||||
|
||||
pub fn verify_password(password: &str, pepper: &str, password_hash: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(password_hash) else {
|
||||
return false;
|
||||
};
|
||||
let password = format!("{password}{pepper}");
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, ApiError> {
|
||||
let session_id = UserSessionId::new(format!("sess_{}", uuid::Uuid::now_v7().simple()));
|
||||
let mut secret_bytes = [0_u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut secret_bytes);
|
||||
let secret = URL_SAFE_NO_PAD.encode(secret_bytes);
|
||||
let expires_at = OffsetDateTime::now_utc()
|
||||
.checked_add(Duration::hours(settings.session_ttl_hours))
|
||||
.ok_or_else(|| ApiError::internal("failed to compute session expiration"))?;
|
||||
|
||||
Ok(SessionCookie {
|
||||
session_id,
|
||||
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
|
||||
expires_at: expires_at
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| {
|
||||
ApiError::internal(format!("failed to format session expiration: {error}"))
|
||||
})?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hash_session_secret(
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
session_secret: &str,
|
||||
) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(session_id.as_str().as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_value.as_bytes());
|
||||
digest.update(b":");
|
||||
digest.update(session_secret.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest.finalize())
|
||||
}
|
||||
|
||||
pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> {
|
||||
Cookie::build((SESSION_COOKIE_NAME, token.to_owned()))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(settings.cookie_secure)
|
||||
.path("/")
|
||||
.max_age(Duration::hours(settings.session_ttl_hours))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> {
|
||||
Cookie::build((SESSION_COOKIE_NAME, String::new()))
|
||||
.http_only(true)
|
||||
.same_site(SameSite::Lax)
|
||||
.secure(settings.cookie_secure)
|
||||
.path("/")
|
||||
.max_age(Duration::seconds(0))
|
||||
.build()
|
||||
}
|
||||
|
||||
pub fn extract_session_token(jar: &CookieJar) -> Option<(UserSessionId, String)> {
|
||||
let cookie = jar.get(SESSION_COOKIE_NAME)?;
|
||||
let value = cookie.value();
|
||||
let (session_id, session_value) = value.split_once('.')?;
|
||||
|
||||
Some((UserSessionId::new(session_id), session_value.to_owned()))
|
||||
}
|
||||
|
||||
pub async fn require_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
||||
request.extensions_mut().insert(session);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
pub async fn require_workspace_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
original_uri: OriginalUri,
|
||||
mut request: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, ApiError> {
|
||||
let session = resolve_authenticated_session(state.clone(), &jar).await?;
|
||||
let workspace_id = workspace_id_from_path(original_uri.path())
|
||||
.ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?;
|
||||
|
||||
let has_access = state
|
||||
.service
|
||||
.user_has_workspace_access(&session.user.id, &workspace_id)
|
||||
.await?;
|
||||
if !has_access {
|
||||
return Err(ApiError::forbidden("workspace access denied"));
|
||||
}
|
||||
|
||||
request.extensions_mut().insert(session);
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
async fn resolve_authenticated_session(
|
||||
state: AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<AuthenticatedSession, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
let session = state
|
||||
.service
|
||||
.get_session(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
|
||||
state.service.touch_session(&session_id).await?;
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn workspace_id_from_path(path: &str) -> Option<WorkspaceId> {
|
||||
let marker = "/api/admin/workspaces/";
|
||||
let (_, suffix) = path.split_once(marker)?;
|
||||
let workspace_id = suffix.split('/').next()?;
|
||||
if workspace_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(WorkspaceId::new(workspace_id))
|
||||
}
|
||||
@@ -15,6 +15,10 @@ use crate::storage::StorageError;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ApiError {
|
||||
#[error("{message}")]
|
||||
Unauthorized { message: String },
|
||||
#[error("{message}")]
|
||||
Forbidden { message: String },
|
||||
#[error("{message}")]
|
||||
Validation { message: String },
|
||||
#[error("{message}")]
|
||||
@@ -26,6 +30,18 @@ pub enum ApiError {
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn unauthorized(message: impl Into<String>) -> Self {
|
||||
Self::Unauthorized {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: impl Into<String>) -> Self {
|
||||
Self::Forbidden {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validation(message: impl Into<String>) -> Self {
|
||||
Self::Validation {
|
||||
message: message.into(),
|
||||
@@ -52,6 +68,8 @@ impl ApiError {
|
||||
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => StatusCode::UNAUTHORIZED,
|
||||
Self::Forbidden { .. } => StatusCode::FORBIDDEN,
|
||||
Self::Validation { .. } => StatusCode::BAD_REQUEST,
|
||||
Self::NotFound { .. } => StatusCode::NOT_FOUND,
|
||||
Self::Conflict { .. } => StatusCode::CONFLICT,
|
||||
@@ -61,6 +79,8 @@ impl ApiError {
|
||||
|
||||
fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Unauthorized { .. } => "unauthorized",
|
||||
Self::Forbidden { .. } => "forbidden",
|
||||
Self::Validation { .. } => "validation_error",
|
||||
Self::NotFound { .. } => "not_found",
|
||||
Self::Conflict { .. } => "conflict",
|
||||
@@ -75,7 +95,9 @@ impl IntoResponse for ApiError {
|
||||
Self::Internal { message } => {
|
||||
error!(error_code = self.code(), error_message = %message)
|
||||
}
|
||||
Self::Validation { message }
|
||||
Self::Unauthorized { message }
|
||||
| Self::Forbidden { message }
|
||||
| Self::Validation { message }
|
||||
| Self::NotFound { message }
|
||||
| Self::Conflict { message } => {
|
||||
warn!(error_code = self.code(), error_message = %message)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod app;
|
||||
mod auth;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod service;
|
||||
@@ -11,7 +12,12 @@ use crank_registry::PostgresRegistry;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
use crate::{app::build_app, service::AdminService, state::AppState};
|
||||
use crate::{
|
||||
app::build_app,
|
||||
auth::{AuthSettings, BootstrapAdminConfig},
|
||||
service::AdminService,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -26,11 +32,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
env::var("CRANK_STORAGE_ROOT").unwrap_or_else(|_| "/var/lib/crank/storage".into()),
|
||||
);
|
||||
let bind_addr = env::var("CRANK_ADMIN_BIND").unwrap_or_else(|_| "0.0.0.0:3001".into());
|
||||
let public_base_url =
|
||||
env::var("CRANK_PUBLIC_BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into());
|
||||
let socket_addr: SocketAddr = bind_addr.parse()?;
|
||||
let registry = PostgresRegistry::connect(&database_url).await?;
|
||||
let state = AppState {
|
||||
service: AdminService::new(registry, storage_root),
|
||||
let auth_settings = AuthSettings {
|
||||
session_secret: env::var("CRANK_SESSION_SECRET")?,
|
||||
password_pepper: env::var("CRANK_PASSWORD_PEPPER")?,
|
||||
session_ttl_hours: env::var("CRANK_SESSION_TTL_HOURS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(24),
|
||||
cookie_secure: public_base_url.starts_with("https://"),
|
||||
bootstrap_admin: BootstrapAdminConfig {
|
||||
email: env::var("CRANK_BOOTSTRAP_ADMIN_EMAIL")?,
|
||||
password: env::var("CRANK_BOOTSTRAP_ADMIN_PASSWORD")?,
|
||||
display_name: env::var("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME")
|
||||
.unwrap_or_else(|_| "Crank Owner".into()),
|
||||
},
|
||||
};
|
||||
let service = AdminService::new(registry, storage_root, auth_settings);
|
||||
service.bootstrap_admin_user().await?;
|
||||
let state = AppState { service };
|
||||
let app = build_app(state);
|
||||
let listener = TcpListener::bind(socket_addr).await?;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod access;
|
||||
pub mod agents;
|
||||
pub mod auth;
|
||||
pub mod auth_profiles;
|
||||
pub mod observability;
|
||||
pub mod operations;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
|
||||
use crate::{
|
||||
auth::{cleared_session_cookie, extract_session_token, session_cookie},
|
||||
error::ApiError,
|
||||
service::LoginPayload,
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(payload): Json<LoginPayload>,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
let (session_data, session) = state.service.login(payload).await?;
|
||||
let cookie_value = format!(
|
||||
"{}.{}",
|
||||
session_data.session_id.as_str(),
|
||||
session_data.value
|
||||
);
|
||||
let jar = jar.add(session_cookie(state.service.auth_settings(), &cookie_value));
|
||||
|
||||
Ok((jar, Json(serde_json::json!(session))))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
if let Some((session_id, session_value)) = extract_session_token(&jar) {
|
||||
state.service.logout(&session_id, &session_value).await?;
|
||||
}
|
||||
|
||||
let jar = jar.remove(cleared_session_cookie(state.service.auth_settings()));
|
||||
Ok((jar, StatusCode::NO_CONTENT))
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let (session_id, session_value) = extract_session_token(&jar)
|
||||
.ok_or_else(|| ApiError::unauthorized("authentication required"))?;
|
||||
let session = state
|
||||
.service
|
||||
.session_response(&session_id, &session_value)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?;
|
||||
|
||||
Ok(Json(serde_json::json!(session)))
|
||||
}
|
||||
@@ -1,25 +1,36 @@
|
||||
use axum::{
|
||||
Json,
|
||||
Extension, Json,
|
||||
extract::{Path, State},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{
|
||||
auth::AuthenticatedSession,
|
||||
error::ApiError,
|
||||
service::{UpdateWorkspacePayload, WorkspacePayload},
|
||||
state::AppState,
|
||||
};
|
||||
|
||||
pub async fn list_workspaces(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
||||
let items = state.service.list_workspaces().await?;
|
||||
pub async fn list_workspaces(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let items = state
|
||||
.service
|
||||
.list_workspaces_for_user(&session.user.id)
|
||||
.await?;
|
||||
Ok(Json(json!({ "items": items })))
|
||||
}
|
||||
|
||||
pub async fn create_workspace(
|
||||
State(state): State<AppState>,
|
||||
Extension(session): Extension<AuthenticatedSession>,
|
||||
Json(payload): Json<WorkspacePayload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let workspace = state.service.create_workspace(payload).await?;
|
||||
let workspace = state
|
||||
.service
|
||||
.create_workspace(&session.user.id, payload)
|
||||
.await?;
|
||||
Ok(Json(json!(workspace)))
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ use crank_core::{
|
||||
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
|
||||
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
|
||||
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
|
||||
Protocol, SampleId, Samples, Target, UsagePeriod, Workspace, WorkspaceId, WorkspaceStatus,
|
||||
Protocol, SampleId, Samples, Target, UsagePeriod, UserSessionId, Workspace, WorkspaceId,
|
||||
WorkspaceStatus,
|
||||
};
|
||||
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
|
||||
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
|
||||
@@ -21,7 +22,7 @@ use crank_registry::{
|
||||
PublishAgentRequest, PublishRequest, RegistryOperation, SampleKind, SaveAgentBindingsRequest,
|
||||
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
|
||||
UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket, UsageOperationBreakdown, UsageQuery,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceRecord,
|
||||
UsageSummary, UsageTimelinePoint, WorkspaceMembershipRecord, WorkspaceRecord,
|
||||
};
|
||||
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
|
||||
use crank_schema::Schema;
|
||||
@@ -32,13 +33,33 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
|
||||
use tracing::{info, instrument};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::ApiError, storage::LocalArtifactStorage};
|
||||
use crate::{
|
||||
auth::{
|
||||
AuthSettings, AuthenticatedSession, SessionCookie, create_session_cookie, hash_password,
|
||||
hash_session_secret, verify_password,
|
||||
},
|
||||
error::ApiError,
|
||||
storage::LocalArtifactStorage,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AdminService {
|
||||
registry: PostgresRegistry,
|
||||
runtime: RuntimeExecutor,
|
||||
storage: LocalArtifactStorage,
|
||||
auth_settings: AuthSettings,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct LoginPayload {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SessionResponse {
|
||||
pub user: crank_core::User,
|
||||
pub memberships: Vec<WorkspaceMembershipRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -421,22 +442,151 @@ fn default_operation_category() -> String {
|
||||
}
|
||||
|
||||
impl AdminService {
|
||||
pub fn new(registry: PostgresRegistry, storage_root: PathBuf) -> Self {
|
||||
pub fn new(
|
||||
registry: PostgresRegistry,
|
||||
storage_root: PathBuf,
|
||||
auth_settings: AuthSettings,
|
||||
) -> Self {
|
||||
Self {
|
||||
registry,
|
||||
runtime: RuntimeExecutor::new(),
|
||||
storage: LocalArtifactStorage::new(storage_root),
|
||||
auth_settings,
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, ApiError> {
|
||||
Ok(self.registry.list_workspaces().await?)
|
||||
pub fn auth_settings(&self) -> &AuthSettings {
|
||||
&self.auth_settings
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_slug = %payload.slug))]
|
||||
pub async fn bootstrap_admin_user(&self) -> Result<(), ApiError> {
|
||||
let password_hash = hash_password(
|
||||
&self.auth_settings.bootstrap_admin.password,
|
||||
&self.auth_settings.password_pepper,
|
||||
)?;
|
||||
let user_id = self
|
||||
.registry
|
||||
.upsert_bootstrap_user(
|
||||
&self.auth_settings.bootstrap_admin.email,
|
||||
&self.auth_settings.bootstrap_admin.display_name,
|
||||
&password_hash,
|
||||
)
|
||||
.await?;
|
||||
self.registry
|
||||
.ensure_membership(
|
||||
&WorkspaceId::new("ws_default"),
|
||||
&user_id,
|
||||
MembershipRole::Owner,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
) -> Result<Option<AuthenticatedSession>, ApiError> {
|
||||
let secret_hash = hash_session_secret(
|
||||
session_id,
|
||||
session_value,
|
||||
&self.auth_settings.session_secret,
|
||||
);
|
||||
let session = self
|
||||
.registry
|
||||
.get_user_session(session_id, &secret_hash)
|
||||
.await?
|
||||
.map(|record| AuthenticatedSession {
|
||||
user: record.user,
|
||||
memberships: record.memberships,
|
||||
});
|
||||
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn touch_session(&self, session_id: &UserSessionId) -> Result<(), ApiError> {
|
||||
self.registry.touch_user_session(session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
&self,
|
||||
payload: LoginPayload,
|
||||
) -> Result<(SessionCookie, SessionResponse), ApiError> {
|
||||
let user = self
|
||||
.registry
|
||||
.get_auth_user_by_email(&payload.email)
|
||||
.await?
|
||||
.ok_or_else(|| ApiError::unauthorized("invalid email or password"))?;
|
||||
|
||||
if !verify_password(
|
||||
&payload.password,
|
||||
&self.auth_settings.password_pepper,
|
||||
&user.password_hash,
|
||||
) {
|
||||
return Err(ApiError::unauthorized("invalid email or password"));
|
||||
}
|
||||
|
||||
let session_cookie = create_session_cookie(&self.auth_settings)?;
|
||||
let secret_hash = hash_session_secret(
|
||||
&session_cookie.session_id,
|
||||
&session_cookie.value,
|
||||
&self.auth_settings.session_secret,
|
||||
);
|
||||
self.registry
|
||||
.create_user_session(
|
||||
&session_cookie.session_id,
|
||||
&user.user.id,
|
||||
&secret_hash,
|
||||
&session_cookie.expires_at,
|
||||
)
|
||||
.await?;
|
||||
let memberships = self
|
||||
.registry
|
||||
.list_workspaces_for_user(&user.user.id)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
session_cookie,
|
||||
SessionResponse {
|
||||
user: user.user,
|
||||
memberships,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
_session_value: &str,
|
||||
) -> Result<(), ApiError> {
|
||||
self.registry.revoke_user_session(session_id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_workspaces_for_user(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
) -> Result<Vec<WorkspaceMembershipRecord>, ApiError> {
|
||||
Ok(self.registry.list_workspaces_for_user(user_id).await?)
|
||||
}
|
||||
|
||||
pub async fn user_has_workspace_access(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
workspace_id: &WorkspaceId,
|
||||
) -> Result<bool, ApiError> {
|
||||
Ok(self
|
||||
.registry
|
||||
.user_has_workspace_access(user_id, workspace_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[instrument(skip(self, payload), fields(workspace_slug = %payload.slug, user_id = %user_id.as_str()))]
|
||||
pub async fn create_workspace(
|
||||
&self,
|
||||
user_id: &crank_core::UserId,
|
||||
payload: WorkspacePayload,
|
||||
) -> Result<WorkspaceRecord, ApiError> {
|
||||
let now = now_string()?;
|
||||
@@ -455,10 +605,28 @@ impl AdminService {
|
||||
workspace: &workspace,
|
||||
})
|
||||
.await?;
|
||||
self.registry
|
||||
.ensure_membership(&workspace.id, user_id, MembershipRole::Owner)
|
||||
.await?;
|
||||
|
||||
Ok(WorkspaceRecord { workspace })
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub async fn session_response(
|
||||
&self,
|
||||
session_id: &UserSessionId,
|
||||
session_value: &str,
|
||||
) -> Result<Option<SessionResponse>, ApiError> {
|
||||
Ok(self
|
||||
.get_session(session_id, session_value)
|
||||
.await?
|
||||
.map(|session| SessionResponse {
|
||||
user: session.user,
|
||||
memberships: session.memberships,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_workspace(
|
||||
&self,
|
||||
workspace_id: &WorkspaceId,
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/wizard.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
</head>
|
||||
<body x-data="agents">
|
||||
|
||||
@@ -75,7 +76,7 @@
|
||||
<span data-i18n="nav.settings">Settings</span>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="user-dropdown-item danger" onclick="localStorage.removeItem('crank_user'); window.location.href='login.html'">
|
||||
<button class="user-dropdown-item danger" onclick="window.CrankAuth.logout()">
|
||||
<svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg>
|
||||
<span data-i18n="nav.logout">Log out</span>
|
||||
</button>
|
||||
|
||||
@@ -23,11 +23,12 @@
|
||||
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
|
||||
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
|
||||
</style>
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
<script src="js/workspace.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<link rel="stylesheet" href="css/login.css">
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/logs.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/settings.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/usage.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
<link rel="stylesheet" href="../../css/layout.css">
|
||||
<link rel="stylesheet" href="../../css/wizard.css">
|
||||
<link rel="stylesheet" href="../../css/pages.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('../login.html');}catch(e){}</script>
|
||||
<script src="../../js/config.js"></script>
|
||||
<script src="../../js/i18n.js"></script>
|
||||
<script src="../../js/workspace.js"></script>
|
||||
<script src="../../js/api.js"></script>
|
||||
<script src="../../js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<link rel="stylesheet" href="css/wizard.css">
|
||||
<link rel="stylesheet" href="css/workspace-setup.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ws-setup-page">
|
||||
|
||||
+2
-1
@@ -9,11 +9,12 @@
|
||||
<link rel="stylesheet" href="css/layout.css">
|
||||
<link rel="stylesheet" href="css/catalog.css">
|
||||
<link rel="stylesheet" href="css/pages.css">
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('html/login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script>window.CrankAuth.guardProtectedPage();</script>
|
||||
</head>
|
||||
<body x-data="catalog()">
|
||||
|
||||
|
||||
+28
-6
@@ -1,5 +1,6 @@
|
||||
(function() {
|
||||
var API_BASE = '/api/admin';
|
||||
var AUTH_BASE = '/api/auth';
|
||||
|
||||
function headers(extra) {
|
||||
return Object.assign({
|
||||
@@ -8,7 +9,7 @@
|
||||
}
|
||||
|
||||
async function request(path, options) {
|
||||
var response = await fetch(API_BASE + path, Object.assign({
|
||||
var response = await fetch(path, Object.assign({
|
||||
credentials: 'same-origin',
|
||||
headers: headers(),
|
||||
}, options || {}));
|
||||
@@ -27,6 +28,9 @@
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
|
||||
window.CrankAuth.handleUnauthorized();
|
||||
}
|
||||
var message = payload && payload.error
|
||||
? payload.error.message
|
||||
: payload && payload.message
|
||||
@@ -44,11 +48,11 @@
|
||||
}
|
||||
|
||||
function get(path) {
|
||||
return request(path);
|
||||
return request(API_BASE + path);
|
||||
}
|
||||
|
||||
function post(path, body) {
|
||||
return request(path, {
|
||||
return request(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
@@ -56,7 +60,7 @@
|
||||
}
|
||||
|
||||
function patch(path, body) {
|
||||
return request(path, {
|
||||
return request(API_BASE + path, {
|
||||
method: 'PATCH',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
@@ -64,7 +68,7 @@
|
||||
}
|
||||
|
||||
function del(path) {
|
||||
return request(path, { method: 'DELETE' });
|
||||
return request(API_BASE + path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
function query(params) {
|
||||
@@ -81,7 +85,7 @@
|
||||
}
|
||||
|
||||
function postBytes(path, bytes, fileName) {
|
||||
return request(path, {
|
||||
return request(API_BASE + path, {
|
||||
method: 'POST',
|
||||
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
|
||||
body: bytes,
|
||||
@@ -89,6 +93,23 @@
|
||||
}
|
||||
|
||||
window.CrankApi = {
|
||||
login: function(payload) {
|
||||
return request(AUTH_BASE + '/login', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
logout: function() {
|
||||
return request(AUTH_BASE + '/logout', {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
},
|
||||
getSession: function() {
|
||||
return request(AUTH_BASE + '/session');
|
||||
},
|
||||
listWorkspaces: function() {
|
||||
return get('/workspaces');
|
||||
},
|
||||
@@ -197,4 +218,5 @@
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
}());
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
(function() {
|
||||
var STORAGE_KEY = 'crank_user';
|
||||
var sessionCache = null;
|
||||
var sessionPromise = null;
|
||||
|
||||
function isLoginPage() {
|
||||
return /\/html\/login\.html$/.test(window.location.pathname);
|
||||
}
|
||||
|
||||
function loginUrl() {
|
||||
return (window.APP_BASE || '') + 'html/login.html';
|
||||
}
|
||||
|
||||
function homeUrl() {
|
||||
return (window.APP_BASE || '') + 'index.html';
|
||||
}
|
||||
|
||||
function clearUserMirror() {
|
||||
sessionCache = null;
|
||||
sessionPromise = null;
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
function primaryMembership(session) {
|
||||
return session && session.memberships && session.memberships.length
|
||||
? session.memberships[0]
|
||||
: null;
|
||||
}
|
||||
|
||||
function initials(displayName, email) {
|
||||
var source = displayName || email || 'Crank';
|
||||
return source
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map(function(part) { return part.charAt(0).toUpperCase(); })
|
||||
.join('') || 'CR';
|
||||
}
|
||||
|
||||
function persistUserMirror(session) {
|
||||
var membership = primaryMembership(session);
|
||||
var user = {
|
||||
id: session.user.id,
|
||||
name: session.user.display_name,
|
||||
email: session.user.email,
|
||||
role: membership ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); }) : 'Viewer',
|
||||
workspace: membership ? membership.workspace.slug : '',
|
||||
workspaceId: membership ? membership.workspace.id : '',
|
||||
initials: initials(session.user.display_name, session.user.email),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||
} catch (_error) {}
|
||||
}
|
||||
|
||||
async function fetchSession(force) {
|
||||
if (!force && sessionCache) {
|
||||
return sessionCache;
|
||||
}
|
||||
if (!force && sessionPromise) {
|
||||
return sessionPromise;
|
||||
}
|
||||
|
||||
sessionPromise = window.CrankApi.getSession()
|
||||
.then(function(session) {
|
||||
sessionCache = session;
|
||||
persistUserMirror(session);
|
||||
return session;
|
||||
})
|
||||
.catch(function(error) {
|
||||
clearUserMirror();
|
||||
throw error;
|
||||
})
|
||||
.finally(function() {
|
||||
sessionPromise = null;
|
||||
});
|
||||
|
||||
return sessionPromise;
|
||||
}
|
||||
|
||||
async function guardProtectedPage() {
|
||||
try {
|
||||
return await fetchSession(false);
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
window.location.replace(loginUrl());
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function guardLoginPage() {
|
||||
try {
|
||||
await fetchSession(false);
|
||||
window.location.replace(homeUrl());
|
||||
} catch (error) {
|
||||
if (!error || error.status !== 401) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email, password) {
|
||||
var session = await window.CrankApi.login({
|
||||
email: email,
|
||||
password: password,
|
||||
});
|
||||
sessionCache = session;
|
||||
persistUserMirror(session);
|
||||
window.location.href = homeUrl();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await window.CrankApi.logout();
|
||||
} finally {
|
||||
clearUserMirror();
|
||||
window.location.href = loginUrl();
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnauthorized() {
|
||||
if (isLoginPage()) {
|
||||
clearUserMirror();
|
||||
return;
|
||||
}
|
||||
clearUserMirror();
|
||||
window.location.replace(loginUrl());
|
||||
}
|
||||
|
||||
window.CrankAuth = {
|
||||
fetchSession: fetchSession,
|
||||
guardProtectedPage: guardProtectedPage,
|
||||
guardLoginPage: guardLoginPage,
|
||||
login: login,
|
||||
logout: logout,
|
||||
handleUnauthorized: handleUnauthorized,
|
||||
};
|
||||
}());
|
||||
@@ -423,8 +423,7 @@ document.addEventListener('alpine:init', function() {
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
localStorage.removeItem('crank_user');
|
||||
window.location.href = (window.APP_BASE || '') + 'html/login.html';
|
||||
window.CrankAuth.logout();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
+40
-32
@@ -1,34 +1,42 @@
|
||||
// If already logged in, redirect to home
|
||||
try {
|
||||
if (localStorage.getItem('crank_user')) {
|
||||
window.location.replace((window.APP_BASE||'')+'index.html');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('email').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
var errEl = document.getElementById('login-error');
|
||||
|
||||
if (!email || !password) {
|
||||
errEl.style.display = 'block';
|
||||
errEl.textContent = 'Please enter your email and password.';
|
||||
return;
|
||||
(function() {
|
||||
function showError(message) {
|
||||
var errorElement = document.getElementById('login-error');
|
||||
errorElement.style.display = 'block';
|
||||
errorElement.textContent = message;
|
||||
}
|
||||
|
||||
// Demo: any non-empty credentials succeed
|
||||
errEl.style.display = 'none';
|
||||
var initials = email.split('@')[0].slice(0, 2).toUpperCase();
|
||||
var displayName = email.split('@')[0].replace(/[._]/g, ' ')
|
||||
.split(' ').map(function(w) { return w.charAt(0).toUpperCase() + w.slice(1); }).join(' ');
|
||||
var user = {
|
||||
name: displayName,
|
||||
email: email,
|
||||
role: 'Admin',
|
||||
workspace: 'acme-workspace',
|
||||
initials: initials,
|
||||
};
|
||||
try { localStorage.setItem('crank_user', JSON.stringify(user)); } catch (ex) {}
|
||||
window.location.href = (window.APP_BASE||'')+'index.html';
|
||||
});
|
||||
function hideError() {
|
||||
var errorElement = document.getElementById('login-error');
|
||||
errorElement.style.display = 'none';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.CrankAuth.guardLoginPage().catch(function(error) {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
var email = document.getElementById('email').value.trim();
|
||||
var password = document.getElementById('password').value;
|
||||
|
||||
if (!email || !password) {
|
||||
showError('Please enter your email and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
hideError();
|
||||
|
||||
try {
|
||||
await window.CrankAuth.login(email, password);
|
||||
} catch (error) {
|
||||
if (error && error.status === 401) {
|
||||
showError('Invalid email or password. Please try again.');
|
||||
return;
|
||||
}
|
||||
showError('Unable to sign in right now. Please try again.');
|
||||
}
|
||||
});
|
||||
});
|
||||
}());
|
||||
|
||||
+60
-56
@@ -1,94 +1,99 @@
|
||||
/**
|
||||
* nav.js — shared navbar logic for non-catalog pages
|
||||
* Handles: auth guard, user dropdown, hamburger, logout
|
||||
*/
|
||||
(function () {
|
||||
|
||||
/* ── Auth guard ── */
|
||||
var STORAGE_KEY = 'crank_user';
|
||||
var user = null;
|
||||
try { user = JSON.parse(localStorage.getItem(STORAGE_KEY)); } catch (e) {}
|
||||
|
||||
if (!user) {
|
||||
var path = window.location.pathname;
|
||||
var isLogin = path.endsWith('login.html') || path.endsWith('/login');
|
||||
if (!isLogin) { window.location.replace((window.APP_BASE||'')+'html/login.html'); return; }
|
||||
function currentUser() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Fill user info once DOM is available ── */
|
||||
function fillUserInfo() {
|
||||
if (!user) return;
|
||||
document.querySelectorAll('.nav-avatar').forEach(function (el) {
|
||||
el.textContent = user.initials || 'AT';
|
||||
var user = currentUser();
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav-avatar').forEach(function (element) {
|
||||
element.textContent = user.initials || 'CR';
|
||||
});
|
||||
var nameEl = document.querySelector('.user-dropdown-name');
|
||||
var roleEl = document.querySelector('.user-dropdown-role');
|
||||
if (nameEl) nameEl.textContent = user.name || 'Operator';
|
||||
if (roleEl) roleEl.textContent = (user.role || 'Admin') + ' · ' + (user.workspace || 'workspace');
|
||||
|
||||
var nameElement = document.querySelector('.user-dropdown-name');
|
||||
var roleElement = document.querySelector('.user-dropdown-role');
|
||||
|
||||
if (nameElement) {
|
||||
nameElement.textContent = user.name || 'Operator';
|
||||
}
|
||||
|
||||
if (roleElement) {
|
||||
roleElement.textContent = (user.role || 'Viewer') + ' · ' + (user.workspace || 'workspace');
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Wire up nav interactions ── */
|
||||
function initNav() {
|
||||
fillUserInfo();
|
||||
|
||||
var dropdown = document.querySelector('.user-dropdown');
|
||||
var avatar = document.querySelector('.nav-avatar');
|
||||
var dropdown = document.querySelector('.user-dropdown');
|
||||
var avatar = document.querySelector('.nav-avatar');
|
||||
var hamburger = document.querySelector('.nav-hamburger');
|
||||
var mobileNav = document.querySelector('.mobile-nav');
|
||||
|
||||
// Initially hide elements controlled by JS
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
if (mobileNav) mobileNav.style.display = 'none';
|
||||
if (dropdown) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
if (mobileNav) {
|
||||
mobileNav.style.display = 'none';
|
||||
}
|
||||
|
||||
// User dropdown toggle
|
||||
if (avatar && dropdown) {
|
||||
avatar.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var showing = dropdown.style.display !== 'none';
|
||||
dropdown.style.display = showing ? 'none' : 'block';
|
||||
avatar.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Hamburger toggle
|
||||
if (hamburger && mobileNav) {
|
||||
hamburger.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
var showing = mobileNav.style.display !== 'none';
|
||||
mobileNav.style.display = showing ? 'none' : 'block';
|
||||
hamburger.classList.toggle('open', !showing);
|
||||
hamburger.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
mobileNav.style.display = mobileNav.style.display === 'none' ? 'block' : 'none';
|
||||
hamburger.classList.toggle('open', mobileNav.style.display !== 'none');
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown / mobile-nav on outside click
|
||||
document.addEventListener('click', function (e) {
|
||||
if (dropdown && !e.target.closest('.user-menu')) {
|
||||
document.addEventListener('click', function (event) {
|
||||
if (dropdown && !event.target.closest('.user-menu')) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
if (mobileNav && !e.target.closest('.navbar') && !e.target.closest('.mobile-nav')) {
|
||||
if (mobileNav && !event.target.closest('.navbar') && !event.target.closest('.mobile-nav')) {
|
||||
mobileNav.style.display = 'none';
|
||||
if (hamburger) hamburger.classList.remove('open');
|
||||
if (hamburger) {
|
||||
hamburger.classList.remove('open');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Action buttons inside dropdown
|
||||
document.querySelectorAll('[data-action="logout"]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
window.location.href = (window.APP_BASE||'')+'html/login.html';
|
||||
document.querySelectorAll('[data-action="logout"]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
window.CrankAuth.logout();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="profile"]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
window.location.href = (window.APP_BASE||'')+'html/settings.html#profile';
|
||||
document.querySelectorAll('[data-action="profile"]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
if (dropdown) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
window.location.href = (window.APP_BASE || '') + 'html/settings.html#profile';
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-action="settings-link"]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
window.location.href = (window.APP_BASE||'')+'html/settings.html';
|
||||
document.querySelectorAll('[data-action="settings-link"]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
if (dropdown) {
|
||||
dropdown.style.display = 'none';
|
||||
}
|
||||
window.location.href = (window.APP_BASE || '') + 'html/settings.html';
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -98,5 +103,4 @@
|
||||
} else {
|
||||
initNav();
|
||||
}
|
||||
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user