feat: add app-level authentication foundation

This commit is contained in:
a.tolmachev
2026-03-30 23:47:09 +03:00
parent 91854a4153
commit ab2e603997
42 changed files with 1624 additions and 236 deletions
+4
View File
@@ -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
View File
@@ -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(),
+193
View File
@@ -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))
}
+23 -1
View File
@@ -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)
+26 -3
View File
@@ -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
View File
@@ -1,5 +1,6 @@
pub mod access;
pub mod agents;
pub mod auth;
pub mod auth_profiles;
pub mod observability;
pub mod operations;
+52
View File
@@ -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)))
}
+15 -4
View File
@@ -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)))
}
+176 -8
View File
@@ -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,
+3 -2
View File
@@ -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>
+3 -2
View File
@@ -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>
+2
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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>
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 @@
);
},
};
}());
+143
View File
@@ -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,
};
}());
+1 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
})();