mirror of
https://github.com/sol1/rustguac.git
synced 2026-09-10 01:26:06 +00:00
v0.8.0: Credential variables, bug fixes
Credential variables — address book entries reference $domain_username / $domain_password instead of storing static credentials. Users fill in their own values via My Credentials (gear menu), stored per-user in Vault KV. All variables set → silent launch; missing → prompted. Hyphens allowed in variable names. Docs section added. Bug fixes: - Rate limiting disabled by default; opt-in via rate_limit = true (#62) - Docker: copy FreeRDP guac-common-svc plugins to runtime image (#64) - Docker/install: add chromium-sandbox package for non-root web sessions (#61) - Logo: skip redundant JS src= when server-side branding already set (#65) - Sessions page: hide Open/Share buttons for non-active sessions (#63) - Drive: expose drive_configured in /api/auth/status, warn in UI when [drive] not configured - install.sh: verify FreeRDP plugin installation UI polish: - Nav bar: border separator + spacing between header and nav on all pages - Address book: password show/hide toggle on all password fields - Drive diagnostic logging (session.rs, websocket.rs, client.html) Closes #61, #62, #63, #64, #65
This commit is contained in:
Generated
+1
-1
@@ -3015,7 +3015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustguac"
|
||||
version = "0.7.2"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustguac"
|
||||
version = "0.7.2"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
description = "Lightweight Rust replacement for Apache Guacamole client"
|
||||
|
||||
|
||||
+16
-3
@@ -56,7 +56,9 @@ RUN /build/guacamole-server/configure \
|
||||
--disable-guaclog \
|
||||
--disable-static \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install
|
||||
&& make install \
|
||||
&& mkdir -p /opt/rustguac/lib/freerdp3 \
|
||||
&& find /usr/lib -path "*/freerdp3/libguac*" -exec cp {} /opt/rustguac/lib/freerdp3/ \;
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stage 2: Build rustguac
|
||||
@@ -87,7 +89,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libfreerdp3-3 libfreerdp-client3-3 libwinpr3-3 \
|
||||
# Xvnc + Chromium for web browser sessions
|
||||
tigervnc-standalone-server \
|
||||
chromium \
|
||||
chromium chromium-sandbox \
|
||||
x11-utils \
|
||||
# Minimal runtime utilities
|
||||
ca-certificates \
|
||||
@@ -106,6 +108,16 @@ COPY static/ /opt/rustguac/static/
|
||||
# Library path for guacd
|
||||
RUN echo "/opt/rustguac/lib" > /etc/ld.so.conf.d/rustguac.conf && ldconfig
|
||||
|
||||
# Symlink FreeRDP plugins (RDPDR/drive, audio) into the system FreeRDP plugin dir.
|
||||
# guacamole-server builds these plugins but installs them relative to the system
|
||||
# FreeRDP path — we copy them under our prefix in the builder, then symlink here.
|
||||
RUN FREERDP_DIR=$(find /usr/lib -name "freerdp3" -type d 2>/dev/null | head -1) && \
|
||||
if [ -n "$FREERDP_DIR" ] && [ -d /opt/rustguac/lib/freerdp3 ]; then \
|
||||
for f in /opt/rustguac/lib/freerdp3/*.so*; do \
|
||||
ln -sf "$f" "$FREERDP_DIR/$(basename "$f")"; \
|
||||
done; \
|
||||
fi
|
||||
|
||||
# Create writable runtime directories
|
||||
RUN mkdir -p /opt/rustguac/data /opt/rustguac/recordings /opt/rustguac/tls \
|
||||
/opt/rustguac/certs /opt/rustguac/drives /opt/rustguac/scripts
|
||||
@@ -174,7 +186,8 @@ fi
|
||||
|
||||
# Start guacd in background
|
||||
echo "Starting guacd..."
|
||||
LD_LIBRARY_PATH=/opt/rustguac/lib /opt/rustguac/sbin/guacd \
|
||||
LD_LIBRARY_PATH=/opt/rustguac/lib FREERDP_ADDIN_PATH=/opt/rustguac/lib/freerdp3 \
|
||||
/opt/rustguac/sbin/guacd \
|
||||
-b 127.0.0.1 -l 4822 -L "${GUACD_LOG_LEVEL:-info}" -f \
|
||||
-C /opt/rustguac/tls/cert.pem -K /opt/rustguac/tls/key.pem &
|
||||
GUACD_PID=$!
|
||||
|
||||
@@ -8,6 +8,7 @@ const DOC_FILES: &[&str] = &[
|
||||
"overview.md",
|
||||
"installation.md",
|
||||
"configuration.md",
|
||||
"credential-variables.md",
|
||||
"web-sessions.md",
|
||||
"security.md",
|
||||
"roles-and-access-control.md",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Credential Variables
|
||||
|
||||
Credential variables let address book entries reference shared credentials by name instead of storing passwords directly. Users maintain their own credential values in Vault via the **My Credentials** dialog (gear menu). When a session launches, rustguac substitutes the variables from the user's saved values.
|
||||
|
||||
This gives a similar experience to LDAP credential passthrough in Apache Guacamole — users log in once and sessions just work — without rustguac needing to bind to LDAP. Credentials stay in Vault, never on disk or in the browser.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **Admin** creates address book entries with variable references like `$corp_username` and `$corp_password` in the credential fields
|
||||
2. **Users** open **My Credentials** from the gear menu and fill in their values (stored per-user in Vault)
|
||||
3. **At connect time**, rustguac substitutes the variables. If all are set, the session launches silently. If any are missing, the user is prompted.
|
||||
|
||||
## Variable naming
|
||||
|
||||
Variables start with `$` and use the pattern `$<domain>_<suffix>`:
|
||||
|
||||
| Pattern | Purpose | Input type |
|
||||
|---------|---------|------------|
|
||||
| `$<domain>_username` | Username | Text |
|
||||
| `$<domain>_password` | Password | Password (masked) |
|
||||
| `$<domain>_domain` | AD/Windows domain | Text |
|
||||
| `$<domain>_key` | SSH private key | Textarea |
|
||||
|
||||
The `<domain>` is a logical name chosen by the admin to group related credentials — for example `corp`, `jumpcloud`, `lab`, or `cloud-prod`. Multiple entries can reference the same domain, so users only configure their credentials once.
|
||||
|
||||
**Allowed characters:** lowercase letters, numbers, underscores, and hyphens. For example: `$corp_username`, `$jump-host_password`, `$cloud-prod_key`.
|
||||
|
||||
## Example
|
||||
|
||||
An admin creates two address book entries:
|
||||
|
||||
- **Production SSH** — username: `$corp_username`, password: `$corp_password`
|
||||
- **Staging SSH** — username: `$corp_username`, password: `$corp_password`
|
||||
|
||||
Both reference the same `corp` domain. A user opens **My Credentials**, fills in their `corp` username and password once, and both entries work without further prompting.
|
||||
|
||||
An entry can also mix variables with static values. For example, an RDP entry might have a static hostname and port but use `$ad_username`, `$ad_password`, and `$ad_domain` for credentials.
|
||||
|
||||
## My Credentials dialog
|
||||
|
||||
Access via the gear icon in the top-right corner of the address book page. The dialog:
|
||||
|
||||
- Shows all credential variables used across entries the user has access to
|
||||
- Groups variables by domain prefix
|
||||
- Indicates how many entries use each variable
|
||||
- Masks password and key fields (saved values are not shown, but a placeholder confirms they exist)
|
||||
- Partial saves work — fill in what you have now, come back later for the rest
|
||||
|
||||
## Graceful degradation
|
||||
|
||||
- **All variables set** — session launches immediately, no prompting
|
||||
- **Some missing** — credential prompt appears with known values pre-filled; user only needs to fill gaps
|
||||
- **None set** — full credential prompt (same as entries without variables)
|
||||
|
||||
## Vault storage
|
||||
|
||||
User credentials are stored in Vault KV v2 at:
|
||||
|
||||
```
|
||||
<base_path>/users/<sanitized_email>
|
||||
```
|
||||
|
||||
Each user gets a single Vault secret containing all their credential key-value pairs. Variable names are the keys, plaintext values are the values. The Vault policy must allow read/write to this path for authenticated users.
|
||||
|
||||
### Required Vault policy
|
||||
|
||||
In addition to the existing address book policy, add:
|
||||
|
||||
```hcl
|
||||
# User credential variables (read/write own credentials)
|
||||
path "secret/data/rustguac/users/*" {
|
||||
capabilities = ["create", "read", "update", "delete"]
|
||||
}
|
||||
path "secret/metadata/rustguac/users/*" {
|
||||
capabilities = ["list", "read", "delete"]
|
||||
}
|
||||
```
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Role | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `GET` | `/api/me/credentials` | operator+ | List own saved variables (passwords masked) |
|
||||
| `PUT` | `/api/me/credentials` | operator+ | Save/update own variables |
|
||||
| `GET` | `/api/credential-variables` | operator+ | List all variables used across accessible entries |
|
||||
+20
-1
@@ -81,7 +81,7 @@ install_deps() {
|
||||
# Xvnc and Chromium for web browser sessions
|
||||
apt-get install -y \
|
||||
tigervnc-standalone-server \
|
||||
chromium \
|
||||
chromium chromium-sandbox \
|
||||
x11-utils
|
||||
|
||||
# Runtime utilities
|
||||
@@ -187,6 +187,25 @@ build_guacd() {
|
||||
info "Installing guacd to $PREFIX..."
|
||||
make install
|
||||
|
||||
# Verify FreeRDP plugins were installed (required for drive redirection + audio)
|
||||
local freerdp_plugin_dir
|
||||
freerdp_plugin_dir=$(pkg-config --variable=libdir freerdp3 2>/dev/null || pkg-config --variable=libdir freerdp2 2>/dev/null)/freerdp3
|
||||
if [[ -d "$freerdp_plugin_dir" ]]; then
|
||||
local plugin_count
|
||||
plugin_count=$(find "$freerdp_plugin_dir" -name "libguac*" 2>/dev/null | wc -l)
|
||||
if [[ "$plugin_count" -gt 0 ]]; then
|
||||
info "FreeRDP plugins installed to $freerdp_plugin_dir ($plugin_count plugins)"
|
||||
else
|
||||
warn "FreeRDP plugins NOT found in $freerdp_plugin_dir — drive redirection will not work"
|
||||
# Try to copy from the build
|
||||
if [[ -d "$BUILD_DIR/guacd-build/src/protocols/rdp/.libs" ]]; then
|
||||
info "Copying FreeRDP plugins manually..."
|
||||
cp -a "$BUILD_DIR/guacd-build/src/protocols/rdp/.libs"/libguac-common-svc-client*.so* "$freerdp_plugin_dir/" 2>/dev/null || true
|
||||
cp -a "$BUILD_DIR/guacd-build/src/protocols/rdp/.libs"/libguacai-client*.so* "$freerdp_plugin_dir/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
info "guacd installed: $PREFIX/sbin/guacd"
|
||||
}
|
||||
|
||||
|
||||
+310
@@ -229,10 +229,12 @@ pub async fn auth_status(
|
||||
Extension(oidc_enabled): Extension<OidcEnabled>,
|
||||
Extension(site_title): Extension<SiteTitle>,
|
||||
Extension(theme): Extension<ThemeData>,
|
||||
Extension(drive_configured): Extension<DriveConfigured>,
|
||||
) -> impl IntoResponse {
|
||||
let mut resp = json!({
|
||||
"oidc_enabled": oidc_enabled.0,
|
||||
"site_title": site_title.0,
|
||||
"drive_configured": drive_configured.0,
|
||||
});
|
||||
resp["theme"] = json!({
|
||||
"admin_preset": theme.admin_preset,
|
||||
@@ -271,6 +273,10 @@ pub struct OidcEnabled(pub bool);
|
||||
#[derive(Clone)]
|
||||
pub struct VaultConfigured(pub bool);
|
||||
|
||||
/// Marker for whether [drive] is configured.
|
||||
#[derive(Clone)]
|
||||
pub struct DriveConfigured(pub bool);
|
||||
|
||||
/// GET /api/recordings — List all recording files. All authenticated roles.
|
||||
pub async fn list_recordings(State(manager): State<AppState>) -> impl IntoResponse {
|
||||
let recording_path = manager.recording_path().to_path_buf();
|
||||
@@ -1217,6 +1223,41 @@ pub async fn ab_connect_entry(
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve credential variable references ($domain_user, $domain_password, etc.)
|
||||
let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() {
|
||||
let user_email = match &id {
|
||||
AuthIdentity::User { email, .. } => Some(email.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(email) = user_email {
|
||||
match vault.get_user_credentials(&email).await {
|
||||
Ok(user_creds) => {
|
||||
match crate::vault::resolve_credential_variables(&ab_entry, &user_creds) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(missing) => {
|
||||
return (
|
||||
StatusCode::PRECONDITION_FAILED,
|
||||
Json(json!({
|
||||
"error": "missing credential variables",
|
||||
"missing_variables": missing,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read user credentials from Vault: {}", e);
|
||||
ab_entry // Fall through with unresolved variables
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ab_entry // API key users — no variable resolution
|
||||
}
|
||||
} else {
|
||||
ab_entry
|
||||
};
|
||||
|
||||
// Map address book entry type to SessionType
|
||||
let session_type = match ab_entry.session_type.as_str() {
|
||||
"ssh" => SessionType::Ssh,
|
||||
@@ -1901,6 +1942,241 @@ pub async fn revoke_my_token(
|
||||
}
|
||||
}
|
||||
|
||||
// ── User credential variables ──
|
||||
|
||||
/// GET /api/me/credentials — List own credential variables (names and masked values).
|
||||
pub async fn get_my_credentials(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Extension(vault): Extension<VaultState>,
|
||||
) -> impl IntoResponse {
|
||||
let email = match identity {
|
||||
Some(Extension(AuthIdentity::User { ref email, .. })) => email.clone(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "OIDC authentication required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
match vault.get_user_credentials(&email).await {
|
||||
Ok(creds) => {
|
||||
// Return variable names with masked values (indicate set vs unset)
|
||||
let masked: serde_json::Map<String, serde_json::Value> = creds
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
let display = if v.is_empty() {
|
||||
"".to_string()
|
||||
} else if k.ends_with("_password") || k.ends_with("_key") {
|
||||
"••••••••".to_string()
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
(
|
||||
k.clone(),
|
||||
json!({ "set": !v.is_empty(), "display": display }),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Json(json!({ "credentials": masked })).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": format!("Failed to read credentials: {}", e)})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// PUT /api/me/credentials — Save credential variables. Operator+ required.
|
||||
pub async fn put_my_credentials(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Extension(vault): Extension<VaultState>,
|
||||
Json(body): Json<serde_json::Value>,
|
||||
) -> impl IntoResponse {
|
||||
let email = match identity {
|
||||
Some(Extension(AuthIdentity::User {
|
||||
ref email,
|
||||
ref role,
|
||||
..
|
||||
})) if role_level(role) >= role_level("operator") => email.clone(),
|
||||
Some(Extension(AuthIdentity::User { .. })) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"error": "operator role required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "OIDC authentication required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// Expect { "credentials": { "corp_user": "alice", "corp_password": "secret", ... } }
|
||||
let creds_val = match body.get("credentials") {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "missing 'credentials' field"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let creds_obj = match creds_val.as_object() {
|
||||
Some(obj) => obj,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "'credentials' must be an object"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// Validate variable names: [a-z0-9_-]+
|
||||
let mut creds = std::collections::HashMap::new();
|
||||
for (k, v) in creds_obj {
|
||||
if !k
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": format!("invalid variable name '{}': use lowercase alphanumeric, underscores and hyphens", k)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let val = v.as_str().unwrap_or("").to_string();
|
||||
creds.insert(k.clone(), val);
|
||||
}
|
||||
|
||||
// Merge with existing credentials (so partial updates work)
|
||||
match vault.get_user_credentials(&email).await {
|
||||
Ok(mut existing) => {
|
||||
for (k, v) in &creds {
|
||||
if v.is_empty() {
|
||||
existing.remove(k); // Empty value = delete
|
||||
} else {
|
||||
existing.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
creds = existing;
|
||||
}
|
||||
Err(VaultError::NotFound) => {} // No existing, use new
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read existing credentials: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match vault.put_user_credentials(&email, &creds).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(user = %email, count = creds.len(), "User credentials updated");
|
||||
Json(json!({"ok": true, "count": creds.len()})).into_response()
|
||||
}
|
||||
Err(e) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": format!("Failed to save credentials: {}", e)})),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/credential-variables — List all unique credential variables across address book entries.
|
||||
/// Returns grouped by domain prefix, with entry counts.
|
||||
pub async fn list_credential_variables(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
Extension(vault): Extension<VaultState>,
|
||||
) -> impl IntoResponse {
|
||||
let id = match identity {
|
||||
Some(Extension(ref id)) if id.has_role("operator") => id.clone(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({"error": "operator role required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let vault = match require_vault(&vault).await {
|
||||
Ok(v) => v,
|
||||
Err(resp) => return resp,
|
||||
};
|
||||
|
||||
// Scan all accessible folders and entries for variable references
|
||||
let folders = match vault.list_folders().await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": format!("Failed to list folders: {}", e)})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let mut all_vars: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
|
||||
for folder in &folders {
|
||||
// Check folder access for this user
|
||||
if check_folder_access(&vault, &folder.scope, &folder.name, &id)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let entries = match vault.list_entries(&folder.scope, &folder.name).await {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry_name in &entries {
|
||||
if let Ok(entry) = vault
|
||||
.get_entry(&folder.scope, &folder.name, entry_name)
|
||||
.await
|
||||
{
|
||||
for var in crate::vault::entry_credential_variables(&entry) {
|
||||
*all_vars.entry(var).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by domain prefix (everything before the last _user/_password/_domain/_key suffix)
|
||||
let mut domains: std::collections::HashMap<String, Vec<serde_json::Value>> =
|
||||
std::collections::HashMap::new();
|
||||
for (var, count) in &all_vars {
|
||||
let domain = var
|
||||
.rsplit_once('_')
|
||||
.map(|(prefix, _suffix)| prefix.to_string())
|
||||
.unwrap_or_else(|| var.clone());
|
||||
domains
|
||||
.entry(domain)
|
||||
.or_default()
|
||||
.push(json!({"name": var, "entry_count": count}));
|
||||
}
|
||||
|
||||
Json(json!({ "variables": all_vars, "domains": domains })).into_response()
|
||||
}
|
||||
|
||||
/// POST /api/admin/user-tokens — Admin creates a token for any user.
|
||||
pub async fn admin_create_user_token(
|
||||
identity: Option<Extension<AuthIdentity>>,
|
||||
@@ -2301,6 +2577,40 @@ pub async fn quick_connect(
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve credential variable references
|
||||
let ab_entry = if !crate::vault::entry_credential_variables(&ab_entry).is_empty() {
|
||||
let user_email = match &id {
|
||||
AuthIdentity::User { email, .. } => Some(email.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(email) = user_email {
|
||||
match vault.get_user_credentials(&email).await {
|
||||
Ok(user_creds) => {
|
||||
match crate::vault::resolve_credential_variables(&ab_entry, &user_creds) {
|
||||
Ok(resolved) => resolved,
|
||||
Err(missing) => {
|
||||
return quick_connect_error(
|
||||
StatusCode::PRECONDITION_FAILED,
|
||||
&format!(
|
||||
"Missing credential variables: {}. Set them in My Credentials.",
|
||||
missing.join(", ")
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to read user credentials from Vault: {}", e);
|
||||
ab_entry
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ab_entry
|
||||
}
|
||||
} else {
|
||||
ab_entry
|
||||
};
|
||||
|
||||
// Check if we need to prompt for credentials before connecting
|
||||
let needs_prompt = ab_entry.session_type != "web"
|
||||
&& (ab_entry.prompt_credentials == Some(true)
|
||||
|
||||
@@ -252,6 +252,12 @@ pub struct Config {
|
||||
#[serde(default = "default_localhost_networks")]
|
||||
pub web_allowed_networks: Vec<String>,
|
||||
|
||||
/// Enable API rate limiting. Default: false.
|
||||
/// When behind a reverse proxy (HAProxy, nginx) or access gateway (KnockNoc),
|
||||
/// rate limiting is typically handled upstream and not needed here.
|
||||
#[serde(default)]
|
||||
pub rate_limit: bool,
|
||||
|
||||
/// Trusted proxy CIDRs. When the connecting IP matches one of these,
|
||||
/// the first address in X-Forwarded-For is used as the real client IP.
|
||||
#[serde(default)]
|
||||
@@ -805,6 +811,7 @@ impl Default for Config {
|
||||
rdp_allowed_networks: default_localhost_networks(),
|
||||
vnc_allowed_networks: default_localhost_networks(),
|
||||
web_allowed_networks: default_localhost_networks(),
|
||||
rate_limit: false,
|
||||
trusted_proxies: Vec::new(),
|
||||
tls: None,
|
||||
oidc: None,
|
||||
|
||||
+54
-33
@@ -14,7 +14,9 @@ mod tunnel;
|
||||
mod vault;
|
||||
mod websocket;
|
||||
|
||||
use crate::api::{AppState, OidcEnabled, SiteTitle, ThemeData, VaultConfigured, VaultState};
|
||||
use crate::api::{
|
||||
AppState, DriveConfigured, OidcEnabled, SiteTitle, ThemeData, VaultConfigured, VaultState,
|
||||
};
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::session::SessionManager;
|
||||
@@ -583,6 +585,7 @@ async fn run_server(config: Config, database: Db) {
|
||||
|
||||
let oidc_enabled = OidcEnabled(oidc_state.is_some());
|
||||
let vault_configured = VaultConfigured(config.vault.is_some());
|
||||
let drive_configured = DriveConfigured(config.drive.is_some());
|
||||
let site_title = SiteTitle(config.site_title.clone());
|
||||
let theme_data = {
|
||||
let (admin_preset, admin_colors) = config
|
||||
@@ -671,6 +674,7 @@ async fn run_server(config: Config, database: Db) {
|
||||
|
||||
// Build TLS connector for guacd if configured
|
||||
let guacd_tls = build_guacd_tls(&config);
|
||||
let rate_limit_enabled = config.rate_limit;
|
||||
|
||||
// Create session manager
|
||||
let manager: AppState = Arc::new(SessionManager::new(config, guacd_tls));
|
||||
@@ -717,36 +721,27 @@ async fn run_server(config: Config, database: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limit configs
|
||||
let api_governor_conf = GovernorConfigBuilder::default()
|
||||
.per_second(20)
|
||||
.burst_size(100)
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("Failed to build API rate limit config");
|
||||
// Rate limiting (disabled by default — handle upstream in reverse proxy)
|
||||
if rate_limit_enabled {
|
||||
tracing::info!("API rate limiting enabled");
|
||||
}
|
||||
|
||||
let session_create_governor_conf = GovernorConfigBuilder::default()
|
||||
.per_second(2)
|
||||
.burst_size(10)
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("Failed to build session creation rate limit config");
|
||||
|
||||
let ws_governor_conf = GovernorConfigBuilder::default()
|
||||
.per_second(5)
|
||||
.burst_size(50)
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("Failed to build WebSocket rate limit config");
|
||||
|
||||
// Session creation route with extra rate limit layer
|
||||
let session_create_route = Router::new()
|
||||
// Session creation route (rate-limited only when enabled)
|
||||
let mut session_create_route = Router::new()
|
||||
.route("/api/sessions", post(api::create_session))
|
||||
.with_state(manager.clone())
|
||||
.layer(GovernorLayer::new(session_create_governor_conf));
|
||||
.with_state(manager.clone());
|
||||
if rate_limit_enabled {
|
||||
let conf = GovernorConfigBuilder::default()
|
||||
.per_second(2)
|
||||
.burst_size(10)
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("Failed to build session creation rate limit config");
|
||||
session_create_route = session_create_route.layer(GovernorLayer::new(conf));
|
||||
}
|
||||
|
||||
// API routes that require authentication
|
||||
let api_routes = Router::new()
|
||||
let mut api_routes = Router::new()
|
||||
.route("/api/sessions", get(api::list_sessions))
|
||||
.route("/api/sessions/{id}", get(api::get_session))
|
||||
.route("/api/sessions/{id}", delete(api::delete_session))
|
||||
@@ -777,6 +772,13 @@ async fn run_server(config: Config, database: Db) {
|
||||
.route("/api/me/tokens", get(api::list_my_tokens))
|
||||
.route("/api/me/tokens", post(api::create_my_token))
|
||||
.route("/api/me/tokens/{id}", delete(api::revoke_my_token))
|
||||
// User credential variables
|
||||
.route("/api/me/credentials", get(api::get_my_credentials))
|
||||
.route("/api/me/credentials", put(api::put_my_credentials))
|
||||
.route(
|
||||
"/api/credential-variables",
|
||||
get(api::list_credential_variables),
|
||||
)
|
||||
// Admin token management
|
||||
.route("/api/admin/user-tokens", get(api::admin_list_user_tokens))
|
||||
.route("/api/admin/user-tokens", post(api::admin_create_user_token))
|
||||
@@ -820,18 +822,36 @@ async fn run_server(config: Config, database: Db) {
|
||||
post(api::ab_connect_entry),
|
||||
)
|
||||
.merge(session_create_route)
|
||||
.with_state(manager.clone())
|
||||
.layer(GovernorLayer::new(api_governor_conf))
|
||||
.with_state(manager.clone());
|
||||
if rate_limit_enabled {
|
||||
let conf = GovernorConfigBuilder::default()
|
||||
.per_second(20)
|
||||
.burst_size(100)
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("Failed to build API rate limit config");
|
||||
api_routes = api_routes.layer(GovernorLayer::new(conf));
|
||||
}
|
||||
let api_routes = api_routes
|
||||
.layer(middleware::from_fn(auth::require_auth))
|
||||
.layer(Extension(vault_client.clone()))
|
||||
.layer(Extension(vault_configured.clone()))
|
||||
.layer(Extension(database.clone()));
|
||||
|
||||
// WebSocket route with optional auth and rate limiting
|
||||
let ws_route = Router::new()
|
||||
// WebSocket route with optional auth
|
||||
let mut ws_route = Router::new()
|
||||
.route("/ws/{session_id}", get(websocket::ws_handler))
|
||||
.with_state(manager.clone())
|
||||
.layer(GovernorLayer::new(ws_governor_conf))
|
||||
.with_state(manager.clone());
|
||||
if rate_limit_enabled {
|
||||
let conf = GovernorConfigBuilder::default()
|
||||
.per_second(5)
|
||||
.burst_size(50)
|
||||
.key_extractor(SmartIpKeyExtractor)
|
||||
.finish()
|
||||
.expect("Failed to build WebSocket rate limit config");
|
||||
ws_route = ws_route.layer(GovernorLayer::new(conf));
|
||||
}
|
||||
let ws_route = ws_route
|
||||
.layer(middleware::from_fn(auth::optional_auth))
|
||||
.layer(Extension(database.clone()));
|
||||
|
||||
@@ -905,6 +925,7 @@ async fn run_server(config: Config, database: Db) {
|
||||
.layer(middleware::from_fn(security_headers))
|
||||
.layer(Extension(tls_enabled))
|
||||
.layer(Extension(oidc_enabled))
|
||||
.layer(Extension(drive_configured))
|
||||
.layer(Extension(site_title))
|
||||
.layer(Extension(theme_data))
|
||||
.layer(Extension(trusted_proxies))
|
||||
|
||||
@@ -418,6 +418,14 @@ impl SessionManager {
|
||||
|
||||
let drive_enabled = drive::is_drive_enabled(&self.config.drive, req.enable_drive);
|
||||
let drive_cfg = drive::drive_config_or_default(&self.config.drive);
|
||||
tracing::info!(
|
||||
%session_id,
|
||||
drive_enabled,
|
||||
entry_enable_drive = ?req.enable_drive,
|
||||
has_drive_config = self.config.drive.is_some(),
|
||||
drive_path = ?drive_cfg.drive_path,
|
||||
"Drive configuration"
|
||||
);
|
||||
|
||||
// Create per-session drive directory for RDP
|
||||
let session_drive_path = if drive_enabled {
|
||||
@@ -440,6 +448,7 @@ impl SessionManager {
|
||||
ignore_cert = rdp_ignore_cert,
|
||||
security = ?rdp_security,
|
||||
enable_drive = rdp_enable_drive,
|
||||
drive_path = ?session_drive_path,
|
||||
domain = ?req.domain,
|
||||
has_password = req.password.is_some(),
|
||||
"RDP session params"
|
||||
|
||||
+245
-1
@@ -9,6 +9,7 @@
|
||||
//! (allowed_groups, description) that controls OIDC group-based access.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -57,7 +58,7 @@ pub struct FolderConfig {
|
||||
}
|
||||
|
||||
/// A connection entry stored in Vault.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct AddressBookEntry {
|
||||
#[serde(rename = "type")]
|
||||
pub session_type: String, // "ssh", "rdp", "vnc", "web"
|
||||
@@ -226,6 +227,9 @@ pub struct EntryInfo {
|
||||
/// Banner text shown before session starts.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<String>,
|
||||
/// Credential variable names referenced by this entry (e.g. ["corp_user", "corp_password"]).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub credential_variables: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<(&str, &AddressBookEntry)> for EntryInfo {
|
||||
@@ -269,6 +273,7 @@ impl From<(&str, &AddressBookEntry)> for EntryInfo {
|
||||
disable_copy: e.disable_copy,
|
||||
disable_paste: e.disable_paste,
|
||||
banner: e.banner.clone(),
|
||||
credential_variables: entry_credential_variables(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -756,6 +761,89 @@ impl VaultClient {
|
||||
s => Err(VaultError::Parse(format!("list failed ({})", s))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── User credential variables ──
|
||||
|
||||
/// Read a user's stored credential variables from Vault.
|
||||
/// Path: `<base_path>/users/<sanitized_email>`
|
||||
pub async fn get_user_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
) -> Result<HashMap<String, String>, VaultError> {
|
||||
let key = sanitize_email_key(email);
|
||||
let path = format!("/v1/{}/data/{}/users/{}", self.mount, self.base_path, key);
|
||||
let resp = self.request(reqwest::Method::GET, &path, None).await?;
|
||||
|
||||
match resp.status().as_u16() {
|
||||
200 => {
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let data = &json["data"]["data"];
|
||||
let map = data
|
||||
.as_object()
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(map)
|
||||
}
|
||||
404 => Ok(HashMap::new()), // No credentials stored yet
|
||||
403 => Err(VaultError::Forbidden),
|
||||
s => Err(VaultError::Parse(format!(
|
||||
"get user credentials failed ({})",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a user's credential variables to Vault (full replace).
|
||||
/// Path: `<base_path>/users/<sanitized_email>`
|
||||
pub async fn put_user_credentials(
|
||||
&self,
|
||||
email: &str,
|
||||
creds: &HashMap<String, String>,
|
||||
) -> Result<(), VaultError> {
|
||||
let key = sanitize_email_key(email);
|
||||
let path = format!("/v1/{}/data/{}/users/{}", self.mount, self.base_path, key);
|
||||
let body = serde_json::json!({ "data": creds });
|
||||
let resp = self
|
||||
.request(reqwest::Method::POST, &path, Some(&body))
|
||||
.await?;
|
||||
|
||||
match resp.status().as_u16() {
|
||||
200 | 204 => Ok(()),
|
||||
403 => Err(VaultError::Forbidden),
|
||||
s => {
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
Err(VaultError::Parse(format!(
|
||||
"put user credentials failed ({}): {}",
|
||||
s, text
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a user's credential variables from Vault.
|
||||
#[allow(dead_code)] // Will be used by admin endpoint
|
||||
pub async fn delete_user_credentials(&self, email: &str) -> Result<(), VaultError> {
|
||||
let key = sanitize_email_key(email);
|
||||
let path = format!(
|
||||
"/v1/{}/metadata/{}/users/{}",
|
||||
self.mount, self.base_path, key
|
||||
);
|
||||
let resp = self.request(reqwest::Method::DELETE, &path, None).await?;
|
||||
|
||||
match resp.status().as_u16() {
|
||||
200 | 204 => Ok(()),
|
||||
404 => Ok(()), // Already gone
|
||||
403 => Err(VaultError::Forbidden),
|
||||
s => Err(VaultError::Parse(format!(
|
||||
"delete user credentials failed ({})",
|
||||
s
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a reqwest HTTP client from a VaultConfig (extracted for testability).
|
||||
@@ -960,6 +1048,88 @@ fn validate_name(name: &str) -> Result<(), VaultError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sanitize an email address for use as a Vault path component.
|
||||
/// Replaces `@` with `_at_` and strips any characters not in `[a-zA-Z0-9._-]`.
|
||||
fn sanitize_email_key(email: &str) -> String {
|
||||
email
|
||||
.replace('@', "_at_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Check if a string is a credential variable reference (starts with `$`).
|
||||
pub fn is_credential_variable(s: &str) -> bool {
|
||||
s.starts_with('$')
|
||||
&& s.len() > 1
|
||||
&& s[1..]
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Extract the variable name from a `$variable` reference.
|
||||
fn variable_name(s: &str) -> Option<&str> {
|
||||
if is_credential_variable(s) {
|
||||
Some(&s[1..])
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all credential variable names referenced by an address book entry.
|
||||
pub fn entry_credential_variables(entry: &AddressBookEntry) -> Vec<String> {
|
||||
[
|
||||
&entry.username,
|
||||
&entry.password,
|
||||
&entry.domain,
|
||||
&entry.private_key,
|
||||
]
|
||||
.iter()
|
||||
.filter_map(|field| field.as_deref())
|
||||
.filter_map(variable_name)
|
||||
.map(|s| s.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve credential variable references in an address book entry.
|
||||
/// Returns the entry with `$var` fields substituted from the user's credential map.
|
||||
/// Fields that are not variable references are left unchanged.
|
||||
/// Returns `Err(vec_of_missing_var_names)` if any referenced variables are missing.
|
||||
pub fn resolve_credential_variables(
|
||||
entry: &AddressBookEntry,
|
||||
user_creds: &HashMap<String, String>,
|
||||
) -> Result<AddressBookEntry, Vec<String>> {
|
||||
let mut resolved = entry.clone();
|
||||
let mut missing = Vec::new();
|
||||
|
||||
fn resolve_field(
|
||||
field: &mut Option<String>,
|
||||
creds: &HashMap<String, String>,
|
||||
missing: &mut Vec<String>,
|
||||
) {
|
||||
if let Some(ref val) = field {
|
||||
if let Some(name) = variable_name(val) {
|
||||
if let Some(resolved_val) = creds.get(name) {
|
||||
*field = Some(resolved_val.clone());
|
||||
} else {
|
||||
missing.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve_field(&mut resolved.username, user_creds, &mut missing);
|
||||
resolve_field(&mut resolved.password, user_creds, &mut missing);
|
||||
resolve_field(&mut resolved.domain, user_creds, &mut missing);
|
||||
resolve_field(&mut resolved.private_key, user_creds, &mut missing);
|
||||
|
||||
if missing.is_empty() {
|
||||
Ok(resolved)
|
||||
} else {
|
||||
Err(missing)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1353,4 +1523,78 @@ mod tests {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sanitize_email_key() {
|
||||
assert_eq!(
|
||||
sanitize_email_key("alice@example.com"),
|
||||
"alice_at_example.com"
|
||||
);
|
||||
assert_eq!(sanitize_email_key("bob+tag@foo.co"), "bobtag_at_foo.co");
|
||||
assert_eq!(sanitize_email_key("../../evil"), "....evil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_credential_variable() {
|
||||
assert!(is_credential_variable("$corp_user"));
|
||||
assert!(is_credential_variable("$lab_password"));
|
||||
assert!(is_credential_variable("$x"));
|
||||
assert!(!is_credential_variable("$"));
|
||||
assert!(!is_credential_variable("plain_text"));
|
||||
assert!(!is_credential_variable(""));
|
||||
assert!(!is_credential_variable("$has spaces"));
|
||||
assert!(!is_credential_variable("$has-dashes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entry_credential_variables() {
|
||||
let mut entry = AddressBookEntry::default();
|
||||
entry.username = Some("$corp_user".into());
|
||||
entry.password = Some("$corp_password".into());
|
||||
entry.domain = Some("CORP".into()); // literal, not a variable
|
||||
let vars = entry_credential_variables(&entry);
|
||||
assert_eq!(vars, vec!["corp_user", "corp_password"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_credential_variables_success() {
|
||||
let mut entry = AddressBookEntry::default();
|
||||
entry.username = Some("$corp_user".into());
|
||||
entry.password = Some("$corp_password".into());
|
||||
entry.domain = Some("CORP".into());
|
||||
entry.hostname = Some("rdp.example.com".into());
|
||||
|
||||
let mut creds = HashMap::new();
|
||||
creds.insert("corp_user".into(), "alice".into());
|
||||
creds.insert("corp_password".into(), "s3cret".into());
|
||||
|
||||
let resolved = resolve_credential_variables(&entry, &creds).unwrap();
|
||||
assert_eq!(resolved.username.as_deref(), Some("alice"));
|
||||
assert_eq!(resolved.password.as_deref(), Some("s3cret"));
|
||||
assert_eq!(resolved.domain.as_deref(), Some("CORP")); // unchanged
|
||||
assert_eq!(resolved.hostname.as_deref(), Some("rdp.example.com")); // unchanged
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_credential_variables_missing() {
|
||||
let mut entry = AddressBookEntry::default();
|
||||
entry.username = Some("$corp_user".into());
|
||||
entry.password = Some("$corp_password".into());
|
||||
|
||||
let creds = HashMap::new(); // empty
|
||||
let err = resolve_credential_variables(&entry, &creds).unwrap_err();
|
||||
assert_eq!(err, vec!["corp_user", "corp_password"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_credential_variables_no_variables() {
|
||||
let mut entry = AddressBookEntry::default();
|
||||
entry.username = Some("alice".into());
|
||||
entry.password = Some("literal_pass".into());
|
||||
|
||||
let creds = HashMap::new();
|
||||
let resolved = resolve_credential_variables(&entry, &creds).unwrap();
|
||||
assert_eq!(resolved.username.as_deref(), Some("alice"));
|
||||
assert_eq!(resolved.password.as_deref(), Some("literal_pass"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,15 @@ async fn guacd_to_ws(
|
||||
|
||||
// Forward to browser via WebSocket
|
||||
let text = String::from_utf8_lossy(data).into_owned();
|
||||
|
||||
// Log filesystem and clipboard instructions from guacd
|
||||
if text.contains(".filesystem,") {
|
||||
tracing::info!("guacd sent filesystem instruction");
|
||||
}
|
||||
if text.contains(".clipboard,") {
|
||||
tracing::info!("guacd sent clipboard instruction to browser");
|
||||
}
|
||||
|
||||
ws.send(Message::Text(text.into())).await?;
|
||||
}
|
||||
|
||||
@@ -331,6 +340,10 @@ async fn ws_to_guacd(
|
||||
let msg = msg?;
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
// Log clipboard instructions from browser → guacd
|
||||
if text.contains(".clipboard,") {
|
||||
tracing::info!("browser sent clipboard instruction to guacd");
|
||||
}
|
||||
guacd.write_all(text.as_bytes()).await?;
|
||||
}
|
||||
Message::Binary(data) => {
|
||||
|
||||
+201
-9
@@ -8,7 +8,7 @@
|
||||
body { font-family: monospace; background-color: var(--bg); background-image: var(--bg-pattern); background-attachment: fixed; min-height: 100vh; color: var(--text); padding: 2em; font-size: 18px; }
|
||||
h1 { color: var(--primary); }
|
||||
a { color: var(--accent); }
|
||||
nav { margin-bottom: 1.5em; font-size: 0.95em; }
|
||||
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
||||
nav a { margin-right: 1.5em; text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
nav .active { color: var(--primary); font-weight: bold; }
|
||||
@@ -155,6 +155,10 @@
|
||||
.modal-actions .btn-cancel:hover { background: #444; }
|
||||
|
||||
.field-hint { font-size: 0.8em; color: var(--text-dim); margin-top: 0.2em; }
|
||||
.pw-wrap { position: relative; }
|
||||
.pw-wrap input { padding-right: 2.2em; }
|
||||
.pw-toggle { position: absolute; right: 0.4em; top: 50%; transform: translateY(-50%); background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 0.85em; padding: 0.2em; line-height: 1; width: auto; }
|
||||
.pw-toggle:hover { color: var(--text); }
|
||||
|
||||
/* Hop cards */
|
||||
.hop-card {
|
||||
@@ -254,7 +258,7 @@
|
||||
<a href="/docs.html">Docs</a>
|
||||
<a href="/tokens.html" id="tokens-link" style="display:none">Tokens</a>
|
||||
<a href="/admin.html" id="admin-link" style="display:none">Admin</a>
|
||||
<span id="user-menu-wrapper"><span id="user-menu-btn" title="Preferences">⚙</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div><div class="um-divider"></div><div class="um-item um-logout" id="logout-item">Logout</div></div></span>
|
||||
<span id="user-menu-wrapper"><span id="user-menu-btn" title="Preferences">⚙</span><div id="user-menu"><div class="um-section-label">Theme</div><div id="um-theme-list"></div><div class="um-divider"></div><div class="um-item" id="my-creds-item">🔑 My Credentials</div><div class="um-divider"></div><div class="um-item um-logout" id="logout-item">Logout</div></div></span>
|
||||
</nav>
|
||||
|
||||
<div id="global-error"></div>
|
||||
@@ -362,7 +366,7 @@
|
||||
<input type="text" id="em-username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" id="em-password">
|
||||
<div class="pw-wrap"><input type="password" id="em-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||||
</label>
|
||||
<label>Private key (PEM)
|
||||
<textarea id="em-private-key" rows="3" style="resize:vertical;font-size:0.85em" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"></textarea>
|
||||
@@ -383,7 +387,7 @@
|
||||
<input type="text" id="em-rdp-username">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" id="em-rdp-password">
|
||||
<div class="pw-wrap"><input type="password" id="em-rdp-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||||
</label>
|
||||
<label>Domain
|
||||
<input type="text" id="em-rdp-domain">
|
||||
@@ -425,7 +429,7 @@
|
||||
<input type="number" id="em-vnc-port" value="5900">
|
||||
</label>
|
||||
<label>Password
|
||||
<input type="password" id="em-vnc-password">
|
||||
<div class="pw-wrap"><input type="password" id="em-vnc-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||||
</label>
|
||||
<label>Color depth
|
||||
<select id="em-vnc-color-depth">
|
||||
@@ -459,7 +463,7 @@
|
||||
<input type="text" id="em-web-username" placeholder="For URL substitution or login script">
|
||||
</label>
|
||||
<label>Password <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||||
<input type="password" id="em-web-password">
|
||||
<div class="pw-wrap"><input type="password" id="em-web-password"><button type="button" class="pw-toggle" onclick="togglePw(this)" title="Show/hide">●</button></div>
|
||||
</label>
|
||||
<label>Login Script <span style="color:var(--text-muted);font-size:0.85em">(optional)</span>
|
||||
<select id="em-login-script">
|
||||
@@ -512,6 +516,7 @@
|
||||
<label>
|
||||
<input type="checkbox" id="em-enable-drive" style="display:inline;width:auto;margin-right:0.4em"> Enable file transfer
|
||||
<div style="color:#888;font-size:0.75em;margin-top:0.2em;">RDP: mounts a shared drive. SSH: enables SFTP file browser.</div>
|
||||
<div id="em-drive-warning" style="display:none;color:var(--primary);font-size:0.8em;margin-top:0.3em">Server has no [drive] section in config — file transfer will not work. See <a href="/docs.html" style="color:var(--accent)">Docs</a> for setup instructions.</div>
|
||||
</label>
|
||||
</div>
|
||||
<div id="em-recording-section" style="display:none;margin-top:0.8em;padding-top:0.8em;border-top:1px solid var(--border)">
|
||||
@@ -571,6 +576,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Credential prompt modal -->
|
||||
<div class="modal-overlay" id="my-creds-modal">
|
||||
<div class="modal" style="max-width:500px">
|
||||
<h3>My Credentials</h3>
|
||||
<p style="color:var(--text-muted);font-size:0.85em;margin-top:0">Store your credentials securely in Vault. Address book entries that use credential variables will auto-fill from these values.</p>
|
||||
<div id="my-creds-loading" style="color:var(--text-muted)">Loading...</div>
|
||||
<div id="my-creds-empty" style="display:none;color:var(--text-muted);font-style:italic;margin:1em 0">No credential variables configured in address book entries yet.</div>
|
||||
<div id="my-creds-list"></div>
|
||||
<div id="my-creds-error" style="color:var(--primary);margin-top:0.5em"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-connect" id="my-creds-save">Save</button>
|
||||
<button class="btn-cancel" id="my-creds-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="cred-modal">
|
||||
<div class="modal">
|
||||
<h3 id="cred-modal-title">Enter Credentials</h3>
|
||||
@@ -597,13 +617,16 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePw(btn){var inp=btn.previousElementSibling;if(inp.type==='password'){inp.type='text';btn.textContent='\u25CB'}else{inp.type='password';btn.textContent='\u25CF'}}
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||||
var _driveConfigured = false;
|
||||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.site_title){document.title=d.site_title+' - Address Book';document.querySelector('h1').textContent=d.site_title;}
|
||||
_driveConfigured = !!d.drive_configured;
|
||||
initTheme(d.theme);
|
||||
});
|
||||
var apiKey = sessionStorage.getItem('rustguac_api_key');
|
||||
@@ -956,14 +979,28 @@
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) return res.text().then(function(t) { throw new Error(t); });
|
||||
if (!res.ok) {
|
||||
if (res.status === 412) {
|
||||
return res.json().then(function(j) {
|
||||
var err = new Error('missing_credentials');
|
||||
err.missing = j.missing_variables || [];
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return res.text().then(function(t) { throw new Error(t); });
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
window.open(data.client_url, '_blank');
|
||||
})
|
||||
.catch(function(err) {
|
||||
showError(err.message);
|
||||
if (err.message === 'missing_credentials') {
|
||||
showError('Missing credentials: ' + (err.missing || []).join(', ') + '. Set them in My Credentials (⚙ menu).');
|
||||
openMyCredentials();
|
||||
} else {
|
||||
showError(err.message);
|
||||
}
|
||||
})
|
||||
.finally(function() {
|
||||
btn.disabled = false;
|
||||
@@ -1291,6 +1328,10 @@
|
||||
});
|
||||
|
||||
// Toggle Autofill fields visibility
|
||||
document.getElementById('em-enable-drive').addEventListener('change', function() {
|
||||
var warn = document.getElementById('em-drive-warning');
|
||||
warn.style.display = (this.checked && !_driveConfigured) ? '' : 'none';
|
||||
});
|
||||
document.getElementById('em-automation-toggle').addEventListener('click', function() {
|
||||
var fields = document.getElementById('em-automation-fields');
|
||||
var arrow = document.getElementById('em-automation-arrow');
|
||||
@@ -1604,6 +1645,7 @@
|
||||
document.getElementById('em-disable-copy').checked = false;
|
||||
document.getElementById('em-disable-paste').checked = false;
|
||||
document.getElementById('em-enable-drive').checked = false;
|
||||
document.getElementById('em-drive-warning').style.display = 'none';
|
||||
document.getElementById('em-remote-app').value = '';
|
||||
document.getElementById('em-remote-app-dir').value = '';
|
||||
document.getElementById('em-remote-app-args').value = '';
|
||||
@@ -1718,6 +1760,7 @@
|
||||
}
|
||||
if (type === 'ssh' || type === 'rdp') {
|
||||
document.getElementById('em-enable-drive').checked = !!entryData.enable_drive;
|
||||
document.getElementById('em-drive-warning').style.display = (entryData.enable_drive && !_driveConfigured) ? '' : 'none';
|
||||
}
|
||||
// Populate RemoteApp fields (RDP)
|
||||
if (type === 'rdp' && (entryData.remote_app || entryData.remote_app_dir || entryData.remote_app_args)) {
|
||||
@@ -1983,6 +2026,155 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── My Credentials ──
|
||||
document.getElementById('my-creds-item').addEventListener('click', function() {
|
||||
document.getElementById('user-menu').style.display = 'none';
|
||||
openMyCredentials();
|
||||
});
|
||||
document.getElementById('my-creds-close').addEventListener('click', function() {
|
||||
document.getElementById('my-creds-modal').classList.remove('active');
|
||||
});
|
||||
document.getElementById('my-creds-save').addEventListener('click', saveMyCredentials);
|
||||
|
||||
function openMyCredentials() {
|
||||
var modal = document.getElementById('my-creds-modal');
|
||||
var list = document.getElementById('my-creds-list');
|
||||
var loading = document.getElementById('my-creds-loading');
|
||||
var empty = document.getElementById('my-creds-empty');
|
||||
var errEl = document.getElementById('my-creds-error');
|
||||
list.innerHTML = '';
|
||||
loading.style.display = '';
|
||||
empty.style.display = 'none';
|
||||
errEl.textContent = '';
|
||||
modal.classList.add('active');
|
||||
|
||||
// Fetch required variables and user's current values in parallel
|
||||
Promise.all([
|
||||
fetch('/api/credential-variables', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.json(); }),
|
||||
fetch('/api/me/credentials', { headers: apiHeaders(), credentials: 'same-origin' }).then(function(r) { return r.json(); })
|
||||
]).then(function(results) {
|
||||
loading.style.display = 'none';
|
||||
var varsData = results[0];
|
||||
var credsData = results[1];
|
||||
var allVars = varsData.variables || {};
|
||||
var domains = varsData.domains || {};
|
||||
var saved = credsData.credentials || {};
|
||||
var varNames = Object.keys(allVars).sort();
|
||||
|
||||
if (varNames.length === 0) {
|
||||
empty.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by domain prefix
|
||||
var domainKeys = Object.keys(domains).sort();
|
||||
domainKeys.forEach(function(domain) {
|
||||
var heading = document.createElement('div');
|
||||
heading.style.cssText = 'color:var(--accent);font-weight:bold;margin-top:1em;margin-bottom:0.3em;font-size:0.95em;text-transform:uppercase';
|
||||
heading.textContent = domain;
|
||||
list.appendChild(heading);
|
||||
|
||||
var vars = domains[domain].sort(function(a, b) {
|
||||
// username before password before key before other
|
||||
function suffixOrder(n) {
|
||||
if (n.endsWith('_username')) return 0;
|
||||
if (n.endsWith('_password')) return 1;
|
||||
if (n.endsWith('_domain')) return 2;
|
||||
if (n.endsWith('_key')) return 3;
|
||||
return 4;
|
||||
}
|
||||
var oa = suffixOrder(a.name), ob = suffixOrder(b.name);
|
||||
return oa !== ob ? oa - ob : a.name.localeCompare(b.name);
|
||||
});
|
||||
vars.forEach(function(v) {
|
||||
var name = v.name;
|
||||
var isSecret = name.endsWith('_password') || name.endsWith('_key');
|
||||
var row = document.createElement('label');
|
||||
row.style.cssText = 'display:block;margin-top:0.5em;color:var(--text-muted);font-size:0.9em';
|
||||
var displayName = name.replace(domain + '_', '');
|
||||
row.textContent = displayName;
|
||||
var hint = document.createElement('span');
|
||||
hint.style.cssText = 'color:var(--text-dim);font-size:0.8em;margin-left:0.5em';
|
||||
hint.textContent = '(' + v.entry_count + ' ' + (v.entry_count === 1 ? 'entry' : 'entries') + ')';
|
||||
row.appendChild(hint);
|
||||
|
||||
var input;
|
||||
if (name.endsWith('_key')) {
|
||||
input = document.createElement('textarea');
|
||||
input.rows = 3;
|
||||
input.placeholder = 'Paste SSH private key';
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.type = isSecret ? 'password' : 'text';
|
||||
input.placeholder = isSecret ? '••••••••' : '';
|
||||
}
|
||||
input.setAttribute('data-var', name);
|
||||
input.className = 'my-cred-input';
|
||||
|
||||
// Pre-fill with saved value (non-secret) or leave blank (secret shows placeholder)
|
||||
var sv = saved[name];
|
||||
if (sv) {
|
||||
if (isSecret) {
|
||||
input.placeholder = '(saved — leave blank to keep)';
|
||||
} else {
|
||||
input.value = sv.display || '';
|
||||
}
|
||||
}
|
||||
|
||||
row.appendChild(input);
|
||||
list.appendChild(row);
|
||||
});
|
||||
});
|
||||
}).catch(function(err) {
|
||||
loading.style.display = 'none';
|
||||
errEl.textContent = 'Failed to load: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function saveMyCredentials() {
|
||||
var inputs = document.querySelectorAll('.my-cred-input');
|
||||
var creds = {};
|
||||
var hasChanges = false;
|
||||
inputs.forEach(function(el) {
|
||||
var name = el.getAttribute('data-var');
|
||||
var val = el.value.trim();
|
||||
if (val) {
|
||||
creds[name] = val;
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
if (!hasChanges) {
|
||||
document.getElementById('my-creds-modal').classList.remove('active');
|
||||
return;
|
||||
}
|
||||
var errEl = document.getElementById('my-creds-error');
|
||||
var btn = document.getElementById('my-creds-save');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Saving...';
|
||||
errEl.textContent = '';
|
||||
|
||||
fetch('/api/me/credentials', {
|
||||
method: 'PUT',
|
||||
headers: apiHeaders({ 'Content-Type': 'application/json' }),
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ credentials: creds })
|
||||
})
|
||||
.then(function(res) {
|
||||
if (!res.ok) return res.json().then(function(j) { throw new Error(j.error || 'Save failed'); });
|
||||
return res.json();
|
||||
})
|
||||
.then(function() {
|
||||
document.getElementById('my-creds-modal').classList.remove('active');
|
||||
})
|
||||
.catch(function(err) {
|
||||
errEl.textContent = err.message;
|
||||
})
|
||||
.finally(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Save';
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
h1 { color: var(--primary); }
|
||||
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
|
||||
a { color: var(--accent); }
|
||||
nav { margin-bottom: 1.5em; font-size: 0.95em; }
|
||||
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
||||
nav a { margin-right: 1.5em; text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
nav .active { color: var(--primary); font-weight: bold; }
|
||||
@@ -172,7 +172,7 @@
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.site_title){document.title=d.site_title+' - Admin';document.querySelector('h1').textContent=d.site_title;}
|
||||
|
||||
+50
-3
@@ -202,6 +202,18 @@
|
||||
|
||||
var tunnel = new Guacamole.WebSocketTunnel(wsUrl);
|
||||
var client = new Guacamole.Client(tunnel);
|
||||
|
||||
// Wrap tunnel.oninstruction to log unique opcodes from guacd (diagnostic)
|
||||
var seenOpcodes = {};
|
||||
var origOnInstruction = tunnel.oninstruction;
|
||||
tunnel.oninstruction = function(opcode, args) {
|
||||
if (!seenOpcodes[opcode]) {
|
||||
seenOpcodes[opcode] = true;
|
||||
if (typeof console !== 'undefined') console.log('[rustguac] instruction: ' + opcode + (args.length ? ' (' + args.length + ' args)' : ''));
|
||||
}
|
||||
if (origOnInstruction) origOnInstruction(opcode, args);
|
||||
};
|
||||
|
||||
var displayEl = document.getElementById('display');
|
||||
displayEl.appendChild(client.getDisplay().getElement());
|
||||
|
||||
@@ -343,11 +355,14 @@
|
||||
scaleDisplay();
|
||||
}
|
||||
|
||||
// ── Auto-sync clipboard on window focus (Chrome only, silent fail) ──
|
||||
// ── Auto-sync clipboard on window focus ──
|
||||
window.addEventListener('focus', function() {
|
||||
if (!panelOpen && navigator.clipboard && navigator.clipboard.readText && !/Firefox/i.test(navigator.userAgent)) {
|
||||
if (!panelOpen && navigator.clipboard && navigator.clipboard.readText) {
|
||||
navigator.clipboard.readText().then(function(text) {
|
||||
if (text && text !== remoteClipboard) sendClipboardToRemote(text);
|
||||
if (text && text !== remoteClipboard) {
|
||||
sendClipboardToRemote(text);
|
||||
remoteClipboard = text;
|
||||
}
|
||||
}).catch(function() {});
|
||||
}
|
||||
});
|
||||
@@ -738,6 +753,38 @@
|
||||
}
|
||||
}, true);
|
||||
|
||||
// ── Ctrl+V clipboard sync ──
|
||||
// Intercept Ctrl+V (capture on document, fires before Guacamole's
|
||||
// handler on displayEl). Reads browser clipboard, syncs to remote,
|
||||
// then sends key events. Firefox shows a one-time permission popup.
|
||||
var _pasteIntercepted = false;
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V') && !e.altKey && !e.repeat && !panelOpen) {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
_pasteIntercepted = true;
|
||||
navigator.clipboard.readText().then(function(text) {
|
||||
if (text && text !== remoteClipboard) {
|
||||
sendClipboardToRemote(text);
|
||||
remoteClipboard = text;
|
||||
}
|
||||
}).catch(function() {}).finally(function() {
|
||||
// Send Ctrl+V key events to remote after clipboard sync
|
||||
client.sendKeyEvent(1, 0x76); // v down
|
||||
client.sendKeyEvent(0, 0x76); // v up
|
||||
});
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
document.addEventListener('keyup', function(e) {
|
||||
if (_pasteIntercepted && (e.key === 'v' || e.key === 'V')) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
_pasteIntercepted = false;
|
||||
}
|
||||
}, true);
|
||||
|
||||
keyboard.onkeydown = function(keysym) {
|
||||
client.sendKeyEvent(1, keysym);
|
||||
};
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@
|
||||
:root { --primary: #e94560; --primary-hover: #c73652; --accent: #5bc0be; --accent-hover: #4aa3a1; --bg: #1a1a2e; --surface: #16213e; --input: #0f3460; --text: #e0e0e0; --text-muted: #aaa; --border: #333; --text-dim: #666; --text-on-primary: #fff; --btn-disabled: #555; --status-pending: #f0c040; --status-active: #5bc0be; --status-completed: #888; --status-error: #e94560; --status-expired: #666; --type-ssh-bg: #1b4332; --type-ssh-fg: #52b788; --type-rdp-bg: #3d1f00; --type-rdp-fg: #f0a050; --type-vnc-bg: #2d1b4e; --type-vnc-fg: #b07ff0; --type-web-bg: #1a1a4e; --type-web-fg: #7b8ff0; --hop-bg: #1b4332; --hop-fg: #52b788; --bg-pattern: none; }
|
||||
body { font-family: monospace; background-color: var(--bg); background-image: var(--bg-pattern); background-attachment: fixed; min-height: 100vh; color: var(--text); padding: 2em; font-size: 18px; }
|
||||
h1 { color: var(--primary); }
|
||||
nav { margin-bottom: 1.5em; font-size: 0.95em; }
|
||||
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
||||
nav a { margin-right: 1.5em; text-decoration: none; color: var(--accent); }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
nav .active { color: var(--primary); font-weight: bold; }
|
||||
@@ -199,7 +199,7 @@
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.site_title){document.title=d.site_title+' - Docs';document.querySelector('h1').textContent=d.site_title;}
|
||||
|
||||
+1
-1
@@ -125,7 +125,7 @@
|
||||
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}}
|
||||
|
||||
var loginForm = document.getElementById('login-form');
|
||||
var apiKeyToggle = document.getElementById('api-key-toggle');
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
body { font-family: monospace; background-color: var(--bg); background-image: var(--bg-pattern); background-attachment: fixed; min-height: 100vh; color: var(--text); padding: 2em; font-size: 18px; }
|
||||
h1 { color: var(--primary); }
|
||||
a { color: var(--accent); }
|
||||
nav { margin-bottom: 1.5em; font-size: 0.95em; }
|
||||
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
||||
nav a { margin-right: 1.5em; text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
nav .active { color: var(--primary); font-weight: bold; }
|
||||
@@ -194,7 +194,7 @@
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.site_title){document.title=d.site_title+' - Recordings';document.querySelector('h1').textContent=d.site_title;}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
|
||||
a { color: var(--accent); }
|
||||
code { background: var(--surface); padding: 0.2em 0.5em; border-radius: 3px; }
|
||||
nav { margin-bottom: 1.5em; font-size: 0.95em; }
|
||||
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
||||
nav a { margin-right: 1.5em; text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
nav .active { color: var(--primary); font-weight: bold; }
|
||||
@@ -320,7 +320,7 @@
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.site_title){document.title=d.site_title+' - Sessions';document.querySelector('h1').textContent=d.site_title;}
|
||||
@@ -708,12 +708,13 @@
|
||||
tr.appendChild(h('td', s.status, { 'class': 'status-' + s.status }));
|
||||
tr.appendChild(h('td', String(s.active_connections)));
|
||||
|
||||
var isActive = s.status === 'active';
|
||||
var tdOpen = document.createElement('td');
|
||||
tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
|
||||
if (isActive) tdOpen.appendChild(h('a', 'open', { href: s.client_url, target: '_blank' }));
|
||||
tr.appendChild(tdOpen);
|
||||
|
||||
var tdShare = document.createElement('td');
|
||||
tdShare.appendChild(h('button', 'share', { 'class': 'btn-small btn-share', 'data-url': shareFullUrl, 'data-row': rowId }));
|
||||
if (isActive) tdShare.appendChild(h('button', 'share', { 'class': 'btn-small btn-share', 'data-url': shareFullUrl, 'data-row': rowId }));
|
||||
tr.appendChild(tdShare);
|
||||
|
||||
var tdDel = document.createElement('td');
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
h1 { color: var(--primary); }
|
||||
h2 { color: var(--primary); font-size: 1em; margin-top: 2em; }
|
||||
a { color: var(--accent); }
|
||||
nav { margin-bottom: 1.5em; font-size: 0.95em; }
|
||||
nav { margin-top: 0.8em; margin-bottom: 1.5em; padding-bottom: 0.8em; border-bottom: 1px solid var(--border); font-size: 0.95em; }
|
||||
nav a { margin-right: 1.5em; text-decoration: none; }
|
||||
nav a:hover { text-decoration: underline; }
|
||||
nav .active { color: var(--primary); font-weight: bold; }
|
||||
@@ -135,7 +135,7 @@
|
||||
function applyThemeColors(colors){var r=document.documentElement.style;for(var k in colors)r.setProperty('--'+k.replace(/_/g,'-'),colors[k]);var s=document.getElementById('bg-pattern-style');if(!s){s=document.createElement('style');s.id='bg-pattern-style';document.head.appendChild(s)}s.textContent=colors.bg_pattern&&colors.bg_pattern!=='none'?'body{background-image:'+colors.bg_pattern+';background-attachment:fixed}':'';localStorage.setItem('rustguac_theme_colors',JSON.stringify(colors))}
|
||||
var _themePresets={},_adminPreset='dark';
|
||||
var _themeDescriptions={dark:'Navy & cyan \u2014 the default',light:'Clean white & blue','high-contrast':'Maximum readability',terminal:'Retro green-on-black',nord:'Arctic, muted blues',corporate:'Slate & steel blue',aurora:'Midnight blue with ambient glow',jaguar:'Racing green & gold'};
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
function initTheme(t){if(!t)return;_themePresets=t.presets||{};_adminPreset=t.admin_preset||'dark';var u=localStorage.getItem('rustguac_theme'),active=u&&_themePresets[u]?u:_adminPreset,colors=(active===_adminPreset)?t.admin_colors:_themePresets[active];if(colors)applyThemeColors(colors);if(t.logo_url){var l=document.getElementById('site-logo');if(l){if(l.src!==t.logo_url&&!l.src.endsWith(t.logo_url))l.src=t.logo_url;l.style.display=''}}var menu=document.getElementById('um-theme-list');if(menu){menu.innerHTML='';Object.keys(_themePresets).forEach(function(name){var item=document.createElement('div');item.className='um-item'+(name===active?' active':'');var sw=document.createElement('span');sw.className='um-swatch';var p=_themePresets[name];sw.style.background='linear-gradient(135deg,'+p.primary+' 50%,'+p.accent+' 50%)';item.appendChild(sw);var info=document.createElement('div');info.className='um-theme-info';var nm=document.createElement('span');nm.className='um-theme-name';nm.textContent=name;info.appendChild(nm);var desc=document.createElement('span');desc.className='um-theme-desc';desc.textContent=_themeDescriptions[name]||'';info.appendChild(desc);item.appendChild(info);item.addEventListener('click',function(){localStorage.setItem('rustguac_theme',name);applyThemeColors(_themePresets[name]);menu.querySelectorAll('.um-item').forEach(function(el){el.classList.remove('active')});item.classList.add('active');document.getElementById('user-menu').style.display='none'});menu.appendChild(item)})}}
|
||||
var _ub=document.getElementById('user-menu-btn');if(_ub)_ub.addEventListener('click',function(e){e.stopPropagation();var m=document.getElementById('user-menu');m.style.display=m.style.display==='block'?'none':'block'});document.addEventListener('click',function(){var m=document.getElementById('user-menu');if(m)m.style.display='none'});
|
||||
fetch('/api/auth/status').then(function(r){return r.json()}).then(function(d){
|
||||
if(d.site_title){document.title=d.site_title+' - API Tokens';document.querySelector('h1').textContent=d.site_title;}
|
||||
|
||||
Reference in New Issue
Block a user