Compare commits

...

7 commits

18 changed files with 588 additions and 62 deletions

View file

@ -8,6 +8,15 @@ DATABASE_URL=postgres://likwid:likwid@localhost:5432/likwid
SERVER_HOST=127.0.0.1
SERVER_PORT=3000
# =============================================================================
# CORS
# =============================================================================
# In development, CORS defaults to allowing any origin.
# In production, set an allowlist (comma-separated) to restrict browser access.
# Example:
# CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
CORS_ALLOWED_ORIGINS=
# JWT Secret for authentication tokens
# IMPORTANT: Change this in production!
JWT_SECRET=change-me-in-production

View file

@ -1,6 +1,7 @@
use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize)]
@ -34,10 +35,14 @@ pub fn create_token(
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 30;
validation.required_spec_claims = HashSet::from(["exp".to_string(), "sub".to_string()]);
let token_data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
&validation,
)?;
Ok(token_data.claims)
}

View file

@ -1,5 +1,6 @@
use axum::{
extract::FromRequestParts,
http::header,
http::{request::Parts, StatusCode},
};
use std::sync::Arc;
@ -8,6 +9,7 @@ use uuid::Uuid;
use super::jwt::{verify_token, Claims};
use crate::config::Config;
#[derive(Debug)]
pub struct AuthUser {
pub user_id: Uuid,
pub username: String,
@ -22,23 +24,39 @@ where
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth_header = parts
.headers
.get("Authorization")
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing authorization header"))?;
let token = auth_header.strip_prefix("Bearer ").ok_or((
let mut pieces = auth_header.split_whitespace();
let scheme = pieces.next().ok_or((
StatusCode::UNAUTHORIZED,
"Invalid authorization header format",
))?;
let token = pieces.next().ok_or((
StatusCode::UNAUTHORIZED,
"Invalid authorization header format",
))?;
if pieces.next().is_some() || !scheme.eq_ignore_ascii_case("bearer") {
return Err((
StatusCode::UNAUTHORIZED,
"Invalid authorization header format",
));
}
let secret = parts
let config = parts
.extensions
.get::<Arc<Config>>()
.map(|c| c.jwt_secret.clone())
.or_else(|| std::env::var("JWT_SECRET").ok())
.unwrap_or_else(|| "dev-secret-change-in-production".to_string());
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth config missing"))?;
let claims: Claims = verify_token(token, &secret)
if config.jwt_secret.trim().is_empty() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
"JWT secret not configured",
));
}
let claims: Claims = verify_token(token, &config.jwt_secret)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid token"))?;
Ok(AuthUser {
@ -47,3 +65,83 @@ where
})
}
}
#[cfg(test)]
mod tests {
use super::AuthUser;
use crate::auth::create_token;
use crate::config::Config;
use axum::body::Body;
use axum::extract::FromRequestParts;
use axum::http::Request;
use std::sync::Arc;
use uuid::Uuid;
fn parts_with_auth(
auth: Option<&str>,
config: Option<Arc<Config>>,
) -> axum::http::request::Parts {
let mut req = Request::builder().uri("/").body(Body::empty()).unwrap();
if let Some(auth) = auth {
req.headers_mut()
.insert(axum::http::header::AUTHORIZATION, auth.parse().unwrap());
}
if let Some(config) = config {
req.extensions_mut().insert(config);
}
let (parts, _) = req.into_parts();
parts
}
#[tokio::test]
async fn rejects_missing_auth_header() {
let config = Arc::new(Config {
jwt_secret: "secret".to_string(),
..Config::default()
});
let mut parts = parts_with_auth(None, Some(config));
let err = AuthUser::from_request_parts(&mut parts, &())
.await
.unwrap_err();
assert_eq!(err.0, axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn accepts_bearer_case_insensitive_and_whitespace() {
let config = Arc::new(Config {
jwt_secret: "secret".to_string(),
..Config::default()
});
let token = create_token(Uuid::new_v4(), "alice", &config.jwt_secret).unwrap();
let auth = format!(" bEaReR {token} ");
let mut parts = parts_with_auth(Some(&auth), Some(config));
let user = AuthUser::from_request_parts(&mut parts, &()).await.unwrap();
assert_eq!(user.username, "alice");
}
#[tokio::test]
async fn rejects_non_bearer_scheme() {
let config = Arc::new(Config {
jwt_secret: "secret".to_string(),
..Config::default()
});
let token = create_token(Uuid::new_v4(), "alice", &config.jwt_secret).unwrap();
let auth = format!("Token {token}");
let mut parts = parts_with_auth(Some(&auth), Some(config));
let err = AuthUser::from_request_parts(&mut parts, &())
.await
.unwrap_err();
assert_eq!(err.0, axum::http::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn errors_when_config_missing() {
let token = create_token(Uuid::new_v4(), "alice", "secret").unwrap();
let auth = format!("Bearer {token}");
let mut parts = parts_with_auth(Some(&auth), None);
let err = AuthUser::from_request_parts(&mut parts, &())
.await
.unwrap_err();
assert_eq!(err.0, axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
}

View file

@ -8,6 +8,8 @@ pub struct Config {
pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16,
#[serde(default)]
pub cors_allowed_origins: Option<String>,
/// Enable demo mode - restricts destructive actions and enables demo accounts
#[serde(default)]
pub demo_mode: bool,
@ -74,6 +76,7 @@ impl Default for Config {
database_url: "postgres://likwid:likwid@localhost:5432/likwid".to_string(),
server_host: "127.0.0.1".to_string(),
server_port: 3000,
cors_allowed_origins: None,
demo_mode: false,
jwt_secret: default_jwt_secret(),
rate_limit_enabled: default_rate_limit_enabled(),

View file

@ -55,6 +55,101 @@ async fn main() {
}
}
#[cfg(test)]
mod security_headers_tests {
use super::add_security_headers;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::middleware;
use axum::response::Response;
use axum::routing::get;
use axum::Router;
use tower::ServiceExt;
#[tokio::test]
async fn security_headers_are_added_by_default() {
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(middleware::map_response(add_security_headers));
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get("x-content-type-options")
.and_then(|v| v.to_str().ok()),
Some("nosniff")
);
assert_eq!(
res.headers()
.get("x-frame-options")
.and_then(|v| v.to_str().ok()),
Some("DENY")
);
assert_eq!(
res.headers()
.get("referrer-policy")
.and_then(|v| v.to_str().ok()),
Some("no-referrer")
);
assert_eq!(
res.headers()
.get("permissions-policy")
.and_then(|v| v.to_str().ok()),
Some("camera=(), microphone=(), geolocation=()")
);
assert_eq!(
res.headers()
.get("x-permitted-cross-domain-policies")
.and_then(|v| v.to_str().ok()),
Some("none")
);
}
#[tokio::test]
async fn security_headers_do_not_override_explicit_headers() {
let app = Router::new()
.route(
"/api/ping",
get(|| async {
Response::builder()
.status(StatusCode::OK)
.header("x-frame-options", "SAMEORIGIN")
.header(header::REFERRER_POLICY, "strict-origin")
.body(Body::from("ok"))
.unwrap()
}),
)
.layer(middleware::map_response(add_security_headers));
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(
res.headers()
.get("x-frame-options")
.and_then(|v| v.to_str().ok()),
Some("SAMEORIGIN")
);
assert_eq!(
res.headers()
.get(header::REFERRER_POLICY)
.and_then(|v| v.to_str().ok()),
Some("strict-origin")
);
}
}
async fn run() -> Result<(), StartupError> {
dotenvy::dotenv().ok();
@ -152,10 +247,7 @@ async fn run() -> Result<(), StartupError> {
}
}
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let cors = build_cors_layer(config.as_ref());
let plugins = plugins::PluginManager::new(pool.clone())
.register_builtin_plugins()
@ -352,5 +444,155 @@ async fn add_security_headers(mut res: Response) -> Response {
);
}
if !headers.contains_key("permissions-policy") {
headers.insert(
HeaderName::from_static("permissions-policy"),
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
);
}
if !headers.contains_key("x-permitted-cross-domain-policies") {
headers.insert(
HeaderName::from_static("x-permitted-cross-domain-policies"),
HeaderValue::from_static("none"),
);
}
res
}
fn build_cors_layer(config: &Config) -> CorsLayer {
let layer = CorsLayer::new().allow_methods(Any).allow_headers(Any);
let allowed = config
.cors_allowed_origins
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty());
let Some(allowed) = allowed else {
return layer.allow_origin(Any);
};
let origins: Vec<HeaderValue> = allowed
.split(',')
.map(|v| v.trim())
.filter(|v| !v.is_empty())
.filter_map(|v| match HeaderValue::from_str(v) {
Ok(hv) => Some(hv),
Err(e) => {
tracing::warn!(origin = v, error = %e, "Invalid CORS origin; ignoring");
None
}
})
.collect();
if origins.is_empty() {
return layer.allow_origin(Any);
}
layer.allow_origin(origins)
}
#[cfg(test)]
mod cors_tests {
use super::build_cors_layer;
use axum::body::Body;
use axum::http::{header, Request, StatusCode};
use axum::routing::get;
use axum::Router;
use tower::ServiceExt;
use crate::config::Config;
#[tokio::test]
async fn cors_default_allows_any_origin() {
let cfg = Config::default();
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(build_cors_layer(&cfg));
let req = Request::builder()
.method("GET")
.uri("/api/ping")
.header(header::ORIGIN, "https://example.com")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert_eq!(
res.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.and_then(|v| v.to_str().ok()),
Some("*")
);
}
#[tokio::test]
async fn cors_allowlist_only_allows_listed_origins() {
let cfg = Config {
cors_allowed_origins: Some("https://a.example, https://b.example".to_string()),
..Default::default()
};
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(build_cors_layer(&cfg));
let allowed_req = Request::builder()
.method("GET")
.uri("/api/ping")
.header(header::ORIGIN, "https://a.example")
.body(Body::empty())
.unwrap();
let allowed_res = app.clone().oneshot(allowed_req).await.unwrap();
assert_eq!(allowed_res.status(), StatusCode::OK);
assert_eq!(
allowed_res
.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.and_then(|v| v.to_str().ok()),
Some("https://a.example")
);
let denied_req = Request::builder()
.method("GET")
.uri("/api/ping")
.header(header::ORIGIN, "https://c.example")
.body(Body::empty())
.unwrap();
let denied_res = app.clone().oneshot(denied_req).await.unwrap();
assert_eq!(denied_res.status(), StatusCode::OK);
assert!(denied_res
.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.is_none());
}
#[tokio::test]
async fn cors_preflight_responds_for_allowed_origin() {
let cfg = Config {
cors_allowed_origins: Some("https://a.example".to_string()),
..Default::default()
};
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
.layer(build_cors_layer(&cfg));
let req = Request::builder()
.method("OPTIONS")
.uri("/api/ping")
.header(header::ORIGIN, "https://a.example")
.header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET")
.body(Body::empty())
.unwrap();
let res = app.oneshot(req).await.unwrap();
assert!(res.status().is_success());
assert_eq!(
res.headers()
.get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
.and_then(|v| v.to_str().ok()),
Some("https://a.example")
);
}
}

View file

@ -123,7 +123,15 @@ fn parse_ip_from_connect_info<B>(request: &Request<B>) -> Option<IpAddr> {
fn parse_user_from_auth(headers: &HeaderMap, secret: &str) -> Option<Uuid> {
let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
let token = auth.strip_prefix("Bearer ")?;
let mut pieces = auth.split_whitespace();
let scheme = pieces.next()?;
let token = pieces.next()?;
if pieces.next().is_some() {
return None;
}
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
}
verify_token(token, secret).ok().map(|c| c.sub)
}
@ -242,12 +250,13 @@ mod tests {
#[tokio::test]
async fn middleware_bypasses_health() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 1;
cfg.rate_limit_user_rpm = 0;
cfg.rate_limit_auth_rpm = 0;
let cfg = Arc::new(cfg);
let cfg = Arc::new(Config {
rate_limit_enabled: true,
rate_limit_ip_rpm: 1,
rate_limit_user_rpm: 0,
rate_limit_auth_rpm: 0,
..Default::default()
});
let app = Router::new()
.route("/health", get(|| async { "ok" }))
@ -270,12 +279,13 @@ mod tests {
#[tokio::test]
async fn middleware_enforces_ip_rate_limit() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 2;
cfg.rate_limit_user_rpm = 0;
cfg.rate_limit_auth_rpm = 0;
let cfg = Arc::new(cfg);
let cfg = Arc::new(Config {
rate_limit_enabled: true,
rate_limit_ip_rpm: 2,
rate_limit_user_rpm: 0,
rate_limit_auth_rpm: 0,
..Default::default()
});
let app = Router::new()
.route("/api/ping", get(|| async { "ok" }))
@ -308,12 +318,13 @@ mod tests {
#[tokio::test]
async fn middleware_enforces_auth_rate_limit_on_login() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 0;
cfg.rate_limit_user_rpm = 0;
cfg.rate_limit_auth_rpm = 1;
let cfg = Arc::new(cfg);
let cfg = Arc::new(Config {
rate_limit_enabled: true,
rate_limit_ip_rpm: 0,
rate_limit_user_rpm: 0,
rate_limit_auth_rpm: 1,
..Default::default()
});
let app = Router::new()
.route("/api/auth/login", get(|| async { "ok" }))
@ -344,13 +355,14 @@ mod tests {
#[tokio::test]
async fn middleware_enforces_user_rate_limit_when_token_is_valid() {
let mut cfg = Config::default();
cfg.rate_limit_enabled = true;
cfg.rate_limit_ip_rpm = 0;
cfg.rate_limit_user_rpm = 1;
cfg.rate_limit_auth_rpm = 0;
cfg.jwt_secret = "testsecret".to_string();
let cfg = Arc::new(cfg);
let cfg = Arc::new(Config {
rate_limit_enabled: true,
rate_limit_ip_rpm: 0,
rate_limit_user_rpm: 1,
rate_limit_auth_rpm: 0,
jwt_secret: "testsecret".to_string(),
..Default::default()
});
let user_id = Uuid::new_v4();
let token = create_token(user_id, "u", &cfg.jwt_secret).unwrap();

View file

@ -13,6 +13,7 @@ Likwid is configured through environment variables and database settings.
| `SERVER_HOST` | No | `127.0.0.1` | Bind address |
| `SERVER_PORT` | No | `3000` | HTTP port |
| `DEMO_MODE` | No | `false` | Enable demo features |
| `CORS_ALLOWED_ORIGINS` | No | - | Comma-separated allowlist of CORS origins for browsers (recommended in production) |
| `RATE_LIMIT_ENABLED` | No | `true` | Enable API rate limiting |
| `RATE_LIMIT_IP_RPM` | No | `300` | Requests per minute per IP |
| `RATE_LIMIT_USER_RPM` | No | `1200` | Requests per minute per authenticated user |
@ -118,12 +119,18 @@ Disabling:
### CORS
By default, CORS allows all origins in development. For production:
By default, CORS allows any origin (development-friendly). In production, set `CORS_ALLOWED_ORIGINS` to a comma-separated allowlist.
```bash
CORS_ALLOWED_ORIGINS=https://likwid.example.org
```
Multiple origins:
```bash
CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
```
## Logging
### Log Levels

View file

@ -26,6 +26,7 @@ cp compose/.env.production.example compose/.env.production
```
Required settings:
- `POSTGRES_PASSWORD` - Strong database password
- `JWT_SECRET` - Random 64+ character string
@ -38,8 +39,8 @@ podman-compose --env-file .env.production -f production.yml up -d
### 4. Access
- Frontend: http://localhost:4321
- Backend API: http://localhost:3000
- Frontend: <http://localhost:4321>
- Backend API: <http://localhost:3000>
## Manual Installation
@ -76,19 +77,21 @@ node ./dist/server/entry.mjs
## Configuration Files
| File | Purpose |
|------|---------|
| `compose/production.yml` | Production container deployment |
| `compose/demo.yml` | Demo instance deployment |
| `compose/.env.production.example` | Environment template |
| `backend/.env` | Backend configuration |
|File|Purpose|
|---|---|
|`compose/production.yml`|Production container deployment|
|`compose/demo.yml`|Demo instance deployment|
|`compose/.env.production.example`|Environment template|
|`backend/.env`|Backend configuration|
## Reverse Proxy
For production, use a reverse proxy (nginx, Caddy) with:
- HTTPS termination
- WebSocket support (for real-time features)
- Proper headers
- HSTS (set on the reverse proxy)
Example nginx config:
@ -107,12 +110,16 @@ server {
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```

View file

@ -5,36 +5,64 @@ Securing your Likwid instance.
## Authentication
### JWT Tokens
- Use a strong, random `JWT_SECRET` (64+ characters)
- Tokens expire after 24 hours by default
- Refresh tokens are not stored server-side
### Password Policy
- Minimum 8 characters (configurable)
- Bcrypt hashing with cost factor 12
- No password in logs or error messages
### Two-Factor Authentication
Enable 2FA support for users:
- TOTP (Time-based One-Time Password)
- Backup codes for recovery
## Network Security
### HTTPS
Always use HTTPS in production:
- Obtain certificates (Let's Encrypt recommended)
- Configure reverse proxy for TLS termination
- Enable HSTS headers
### Security Headers
The backend sets a small set of security headers on responses by default:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY`
- `Referrer-Policy: no-referrer`
- `Permissions-Policy: camera=(), microphone=(), geolocation=()`
- `X-Permitted-Cross-Domain-Policies: none`
Set `Strict-Transport-Security` (HSTS) on your reverse proxy, because the backend does not know whether requests arrived via HTTPS.
### CORS
Restrict CORS in production:
```
```bash
CORS_ALLOWED_ORIGINS=https://likwid.example.org
```
For multiple origins, use a comma-separated list:
```bash
CORS_ALLOWED_ORIGINS=https://openlikwid.org,https://staging.openlikwid.org
```
### Rate Limiting
Protect against abuse:
- 300 requests/minute per IP (default)
- 1200 requests/minute per authenticated user
- 30 requests/minute per IP for auth endpoints
@ -42,11 +70,13 @@ Protect against abuse:
## Database Security
### Connection
- Use SSL for database connections
- Dedicated database user with minimal privileges
- Strong, unique password
### Backups
- Regular automated backups
- Encrypted backup storage
- Test restore procedures
@ -54,16 +84,20 @@ Protect against abuse:
## API Security
### Input Validation
All inputs are validated:
- Type checking
- Length limits
- Sanitization
### SQL Injection
- Parameterized queries only (SQLx)
- No raw SQL string concatenation
### XSS Prevention
- HTML escaping in templates
- Content Security Policy headers
- No inline scripts in production
@ -71,6 +105,7 @@ All inputs are validated:
## Moderation Audit Trail
All moderation actions are logged:
- Who performed the action
- What action was taken
- Why (reason required)
@ -81,6 +116,7 @@ Logs are immutable and tamper-evident.
## Updates
Keep Likwid updated:
- Watch the repository for security announcements
- Apply patches promptly
- Test updates in staging first
@ -88,6 +124,7 @@ Keep Likwid updated:
## Incident Response
If you discover a security issue:
1. Document the incident
2. Assess impact
3. Contain the breach
@ -96,6 +133,6 @@ If you discover a security issue:
## Reporting Vulnerabilities
Report security issues to: security@likwid.org
Report security issues to: [security@likwid.org](mailto:security@likwid.org)
We follow responsible disclosure practices.

View file

@ -53,6 +53,15 @@
font-size: 0.9rem;
cursor: pointer;
min-width: 200px;
width: 100%;
max-width: 320px;
}
@media (max-width: 480px) {
.theme-select {
min-width: 0;
max-width: 100%;
}
}
.theme-select:hover {

View file

@ -118,6 +118,10 @@
overflow: hidden;
}
#main-content {
scroll-margin-top: 6rem;
}
.skip-link {
position: fixed;
top: 0.75rem;
@ -138,6 +142,18 @@
transform: translateY(0);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
::selection {
background: var(--color-primary-muted);
}
@ -159,11 +175,11 @@
color: var(--color-primary-hover);
}
:where(a, button, input, textarea, select):focus {
:where(a, button, input, textarea, select, summary):focus {
outline: none;
}
:where(a, button, input, textarea, select):focus-visible {
:where(a, button, input, textarea, select, summary):focus-visible {
box-shadow: var(--focus-ring);
border-radius: var(--radius-sm);
}
@ -1016,4 +1032,20 @@
justify-content: flex-start;
}
}
@media (max-width: 420px) {
.ui-btn {
white-space: normal;
text-align: center;
}
.ui-btn-lg {
padding: 0.8rem 1.25rem;
}
.ui-btn-xl {
padding: 0.9rem 1.25rem;
font-size: 1rem;
}
}
</style>

View file

@ -128,6 +128,12 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
navToggle.setAttribute('aria-expanded', 'true');
if (window.matchMedia('(max-width: 640px)').matches) {
document.body.classList.add('is-scroll-locked');
const firstFocusable = nav.querySelector(
'#nav-menu a, #nav-menu button, #nav-menu input, #nav-menu select, #nav-menu textarea, #nav-menu summary'
);
if (firstFocusable instanceof HTMLElement) {
firstFocusable.focus();
}
}
}
@ -136,6 +142,7 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
nav.classList.remove('is-open');
navToggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('is-scroll-locked');
navToggle.focus();
}
if (nav && navToggle) {
@ -187,8 +194,9 @@ const publicDemoSite = isEnabled((globalThis as any).process?.env?.PUBLIC_DEMO_S
const userData = JSON.parse(user);
navAuth.innerHTML = `
<a href="/dashboard">Dashboard</a>
<a href="/notifications" class="nav-notifications" id="nav-notifications">
<a href="/notifications" class="nav-notifications" id="nav-notifications" aria-label="Notifications">
<span class="notif-icon">🔔</span>
<span class="sr-only">Notifications</span>
<span class="notif-badge" id="notif-badge" style="display:none;">0</span>
</a>
<a href="/settings">Settings</a>

View file

@ -165,6 +165,12 @@ const defaultTheme = DEFAULT_THEME;
toggle.setAttribute('aria-expanded', 'true');
if (window.matchMedia('(max-width: 768px)').matches) {
document.body.classList.add('is-scroll-locked');
const firstFocusable = nav.querySelector(
'#public-nav-menu a, #public-nav-menu button, #public-nav-menu input, #public-nav-menu select, #public-nav-menu textarea, #public-nav-menu summary'
);
if (firstFocusable instanceof HTMLElement) {
firstFocusable.focus();
}
}
}
@ -173,6 +179,7 @@ const defaultTheme = DEFAULT_THEME;
nav.classList.remove('is-open');
toggle.setAttribute('aria-expanded', 'false');
document.body.classList.remove('is-scroll-locked');
toggle.focus();
}
function setActiveNav() {

View file

@ -56,6 +56,7 @@ import { API_BASE as apiBase } from '../lib/api';
var allCommunities = [];
var currentQuery = '';
var currentSort = 'name_asc';
var token = localStorage.getItem('token');
function escapeHtml(value) {
return String(value || '').replace(/[&<>\"']/g, function(ch) {
@ -122,8 +123,18 @@ import { API_BASE as apiBase } from '../lib/api';
var container = document.getElementById('communities-list');
if (!container) return;
var label = isFiltered ? 'No communities match your search.' : 'No communities found.';
var hint = isFiltered ? 'Try a different keyword or clear the search.' : 'Please try again later.';
var isLoggedIn = !!token;
var label;
var hint;
if (isFiltered) {
label = 'No communities match your search.';
hint = 'Try a different keyword or clear the search.';
} else {
label = isLoggedIn ? 'No communities yet.' : 'No communities found.';
hint = isLoggedIn
? 'Create the first community to start organizing proposals.'
: 'Sign in to create a community, or refresh to try again.';
}
container.innerHTML =
'<div class="state-card ui-card">' +
@ -131,6 +142,8 @@ import { API_BASE as apiBase } from '../lib/api';
'<p class="hint">' + hint + '</p>' +
'<div class="state-actions">' +
(isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-community-search">Reset search</button>' : '') +
(!isFiltered && isLoggedIn ? '<a class="ui-btn ui-btn-primary" href="/communities/new">Create Community</a>' : '') +
(!isFiltered && !isLoggedIn ? '<a class="ui-btn ui-btn-secondary" href="/login">Sign In</a>' : '') +
'<a class="ui-btn ui-btn-primary" href="/communities">Refresh</a>' +
'</div>' +
'</div>';
@ -265,7 +278,6 @@ import { API_BASE as apiBase } from '../lib/api';
}
// Show create button if logged in
var token = localStorage.getItem('token');
var createBtn = document.getElementById('create-btn');
if (token && createBtn) {
createBtn.style.display = 'block';

View file

@ -72,7 +72,7 @@ const { slug } = Astro.params;
</div>
</div>
<div id="proposals-list" class="list">
<div id="proposals-list" class="list" aria-live="polite">
<div class="list-skeleton">
<div class="skeleton-card"></div>
<div class="skeleton-card"></div>
@ -154,7 +154,18 @@ const { slug } = Astro.params;
if (!container) return;
if (proposals.length === 0) {
container.innerHTML = '<div class="ui-card"><p class="empty">No proposals found.</p></div>';
const token = localStorage.getItem('token');
const isLoggedIn = !!token;
container.innerHTML = `
<div class="state-card ui-card">
<p class="empty">No proposals yet.</p>
<p class="hint">Create the first proposal for this community to start collecting feedback and votes.</p>
<div class="state-actions">
${isLoggedIn ? `<a class="ui-btn ui-btn-primary" href="/communities/${encodeURIComponent(String(slug))}/proposals/new">+ New Proposal</a>` : '<a class="ui-btn ui-btn-secondary" href="/login">Sign In</a>'}
<a class="ui-btn ui-btn-secondary" href="/communities/${encodeURIComponent(String(slug))}">Back to community</a>
</div>
</div>
`;
return;
}

View file

@ -531,6 +531,7 @@ const nextQuery = nextParam ? `&next=${encodeURIComponent(nextParam)}` : '';
.card-stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
padding-top: 1rem;
border-top: 1px solid var(--color-border);
font-size: 0.8125rem;
@ -708,6 +709,14 @@ const nextQuery = nextParam ? `&next=${encodeURIComponent(nextParam)}` : '';
align-items: flex-start;
}
@media (max-width: 480px) {
.journey-step {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
}
.step-number {
width: 36px;
height: 36px;

View file

@ -100,13 +100,18 @@ import { API_BASE as apiBase } from '../lib/api';
const container = document.getElementById('proposals-list');
if (!container) return;
const label = isFiltered ? 'No proposals match your filters.' : 'No proposals yet.';
const hint = isFiltered
? 'Try adjusting search, status, or sort.'
: 'To create one, open a community and click New Proposal.';
container.innerHTML = `
<div class="state-card ui-card">
<p class="empty">${isFiltered ? 'No proposals match your filters.' : 'No proposals found.'}</p>
<p class="hint">Try adjusting search, status, or sort.</p>
<p class="empty">${label}</p>
<p class="hint">${hint}</p>
<div class="state-actions">
<button type="button" class="ui-btn ui-btn-secondary" id="reset-filters">Reset filters</button>
<a class="ui-btn ui-btn-primary" href="/communities">Go to communities</a>
${isFiltered ? '<button type="button" class="ui-btn ui-btn-secondary" id="reset-filters">Reset filters</button>' : ''}
<a class="ui-btn ui-btn-primary" href="/communities">Browse communities</a>
</div>
</div>
`;

View file

@ -181,6 +181,8 @@ import { API_BASE as apiBase } from '../lib/api';
.settings-page {
padding: 2rem 0;
max-width: 500px;
width: 100%;
margin: 0 auto;
--ui-form-group-mb: 1.25rem;
}
@ -189,6 +191,17 @@ import { API_BASE as apiBase } from '../lib/api';
margin-bottom: 2rem;
}
@media (max-width: 640px) {
.settings-page {
padding: 1.5rem 0;
}
h1 {
font-size: 1.75rem;
margin-bottom: 1.5rem;
}
}
.settings-form {
display: flex;
flex-direction: column;