Implement user-bound join flows and add admin image build pipeline
Some checks failed
build-local-image / build (push) Has been cancelled
Some checks failed
build-local-image / build (push) Has been cancelled
This commit is contained in:
parent
6ae8b6e898
commit
98eb7057a5
16 changed files with 1000 additions and 113 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
.git
|
||||
.gitignore
|
||||
target
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/target
|
||||
Dockerfile*
|
||||
.forgejo
|
||||
20
.forgejo/workflows/build-local-image.yml
Normal file
20
.forgejo/workflows/build-local-image.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
name: build-local-image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: nix-host
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build local image on runner host
|
||||
run: docker build --pull -t lightscale-admin:local .
|
||||
|
||||
- name: Show built image
|
||||
run: docker image ls lightscale-admin:local
|
||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim AS frontend-build
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM rust:1.88-bookworm AS backend-build
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY backend ./backend
|
||||
RUN cargo build --release -p lightscale-admin-server
|
||||
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --system --create-home --uid 10001 lightscale
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=backend-build /src/target/release/lightscale-admin-server /usr/local/bin/lightscale-admin-server
|
||||
COPY --from=frontend-build /src/frontend/dist /app/frontend/dist
|
||||
|
||||
USER lightscale
|
||||
EXPOSE 8081
|
||||
|
||||
ENV RUST_LOG=info
|
||||
ENV LS_ADMIN__SERVER__BIND=0.0.0.0:8081
|
||||
ENV LS_ADMIN__SERVER__STATIC_DIR=/app/frontend/dist
|
||||
|
||||
CMD ["/usr/local/bin/lightscale-admin-server"]
|
||||
32
README.md
32
README.md
|
|
@ -6,6 +6,37 @@ A thin admin control plane for Lightscale. It stores operator metadata in Cockro
|
|||
- `backend/`: Rust (Axum) API server, `/admin/api` namespace.
|
||||
- `frontend/`: Vite React SPA.
|
||||
|
||||
## Features
|
||||
|
||||
### Authentication
|
||||
- Local account authentication
|
||||
- OIDC authentication
|
||||
- Session management
|
||||
- Bootstrap admin creation
|
||||
|
||||
### RBAC
|
||||
- Role-based access control
|
||||
- 5 default roles (Owner, Admin, Viewer, Member, Joiner)
|
||||
- Membership management
|
||||
- `console:access` permission gate for admin console/API access
|
||||
- `join_tokens:create` permission for non-console self-service device enrollment
|
||||
|
||||
### Control Plane Management
|
||||
- CRUD operations (Create, Read, Update, Delete)
|
||||
- Health check/verification
|
||||
|
||||
### Network Management
|
||||
- Create/Delete/List networks
|
||||
- Node management (approve, revoke, key rotation)
|
||||
- Token management (create, revoke)
|
||||
- Self-service join token APIs (`/admin/api/auth/join-networks`, `/admin/api/auth/join-token`)
|
||||
- ACL configuration
|
||||
- Key policy configuration
|
||||
|
||||
### Audit
|
||||
- Admin audit log
|
||||
- Control Plane audit log
|
||||
|
||||
## Quick start
|
||||
1) Start CockroachDB (single node for local dev):
|
||||
|
||||
|
|
@ -45,6 +76,7 @@ Key settings:
|
|||
- `auth.bootstrap_admin_email` / `auth.bootstrap_admin_password`: creates the first admin if the database is empty.
|
||||
- `server.allowed_origins`: set when the UI is hosted separately (CORS + cookies).
|
||||
- `server.static_dir`: serve the SPA from this folder (usually `../frontend/dist`).
|
||||
- `database.disable_migration_locking`: optional override to disable SQLx migration advisory locks (`LS_ADMIN__DATABASE__DISABLE_MIGRATION_LOCKING=true`). CockroachDB is auto-detected.
|
||||
|
||||
## Control planes
|
||||
Create control planes in the UI and store their admin tokens. The admin API will call each control plane’s `/v1/*` endpoints to manage networks and nodes.
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ INSERT INTO roles (id, name, description, created_at)
|
|||
VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'owner', 'Full access', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'admin', 'Network admin access', now()),
|
||||
('00000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access', now());
|
||||
('00000000-0000-0000-0000-000000000003', 'viewer', 'Read-only access', now()),
|
||||
('00000000-0000-0000-0000-000000000004', 'member', 'Authenticated user without console access', now()),
|
||||
('00000000-0000-0000-0000-000000000005', 'joiner', 'Can mint user-bound join tokens (typically network-scoped)', now());
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission, created_at)
|
||||
VALUES
|
||||
|
|
@ -124,6 +126,7 @@ VALUES
|
|||
('00000000-0000-0000-0000-000000000001', 'nodes:read', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'nodes:write', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'tokens:write', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'join_tokens:create', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'acl:read', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'acl:write', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'key_policy:read', now()),
|
||||
|
|
@ -133,6 +136,7 @@ VALUES
|
|||
('00000000-0000-0000-0000-000000000001', 'users:write', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'roles:read', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'roles:write', now()),
|
||||
('00000000-0000-0000-0000-000000000001', 'console:access', now()),
|
||||
|
||||
('00000000-0000-0000-0000-000000000002', 'control_planes:read', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'networks:read', now()),
|
||||
|
|
@ -140,6 +144,7 @@ VALUES
|
|||
('00000000-0000-0000-0000-000000000002', 'nodes:read', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'nodes:write', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'tokens:write', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'join_tokens:create', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'acl:read', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'acl:write', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'key_policy:read', now()),
|
||||
|
|
@ -147,9 +152,13 @@ VALUES
|
|||
('00000000-0000-0000-0000-000000000002', 'audit:read', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'users:read', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'roles:read', now()),
|
||||
('00000000-0000-0000-0000-000000000002', 'console:access', now()),
|
||||
|
||||
('00000000-0000-0000-0000-000000000003', 'control_planes:read', now()),
|
||||
('00000000-0000-0000-0000-000000000003', 'networks:read', now()),
|
||||
('00000000-0000-0000-0000-000000000003', 'nodes:read', now()),
|
||||
('00000000-0000-0000-0000-000000000003', 'audit:read', now()),
|
||||
('00000000-0000-0000-0000-000000000003', 'roles:read', now());
|
||||
('00000000-0000-0000-0000-000000000003', 'roles:read', now()),
|
||||
('00000000-0000-0000-0000-000000000003', 'console:access', now()),
|
||||
|
||||
('00000000-0000-0000-0000-000000000005', 'join_tokens:create', now());
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
use crate::api::{ApiError, AuthUser};
|
||||
use crate::api::ApiError;
|
||||
use crate::app_state::AppState;
|
||||
use crate::auth::{create_session, delete_session, hash_password, verify_password, SESSION_COOKIE};
|
||||
use crate::audit_log;
|
||||
use crate::auth::{
|
||||
create_session, delete_session, hash_password, session_user, verify_password, SESSION_COOKIE,
|
||||
};
|
||||
use crate::control_plane::{CreateTokenRequest as CpCreateTokenRequest, EnrollmentToken};
|
||||
use crate::models::User;
|
||||
use crate::oidc::{build_auth_request, exchange_code};
|
||||
use crate::permissions::{
|
||||
ensure_permission, list_permissions_global, PERM_CONSOLE_ACCESS, PERM_JOIN_TOKENS_CREATE,
|
||||
PERM_TOKENS_WRITE,
|
||||
};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::Redirect;
|
||||
use axum::routing::{get, post};
|
||||
|
|
@ -16,6 +24,12 @@ use sqlx::{FromRow, PgPool};
|
|||
use time::{Duration, OffsetDateTime};
|
||||
use uuid::Uuid;
|
||||
|
||||
const JOIN_SCOPE_TYPE_NETWORK: &str = "network";
|
||||
const DEFAULT_JOIN_TOKEN_TTL_SECONDS: u64 = 600;
|
||||
const MAX_JOIN_TOKEN_TTL_SECONDS: u64 = 86_400;
|
||||
const DEFAULT_JOIN_TOKEN_USES: u32 = 1;
|
||||
const MAX_JOIN_TOKEN_USES: u32 = 5;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginRequest {
|
||||
email: String,
|
||||
|
|
@ -25,6 +39,8 @@ struct LoginRequest {
|
|||
#[derive(Debug, Serialize)]
|
||||
struct LoginResponse {
|
||||
user: UserSummary,
|
||||
permissions: Vec<String>,
|
||||
console_access: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
|
@ -61,11 +77,55 @@ struct OidcStateRow {
|
|||
expires_at: OffsetDateTime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateJoinTokenRequest {
|
||||
network_id: Uuid,
|
||||
ttl_seconds: Option<u64>,
|
||||
uses: Option<u32>,
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CreateJoinTokenResponse {
|
||||
network: JoinableNetwork,
|
||||
token: EnrollmentToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JoinableNetwork {
|
||||
id: Uuid,
|
||||
control_plane_id: Uuid,
|
||||
control_plane_name: String,
|
||||
network_id: String,
|
||||
name: String,
|
||||
dns_domain: Option<String>,
|
||||
overlay_v4: Option<String>,
|
||||
overlay_v6: Option<String>,
|
||||
requires_approval: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct JoinableNetworkRow {
|
||||
id: Uuid,
|
||||
control_plane_id: Uuid,
|
||||
control_plane_name: String,
|
||||
network_id: String,
|
||||
name: String,
|
||||
dns_domain: Option<String>,
|
||||
overlay_v4: Option<String>,
|
||||
overlay_v6: Option<String>,
|
||||
requires_approval: bool,
|
||||
base_url: String,
|
||||
admin_token: Option<String>,
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/login", post(login))
|
||||
.route("/logout", post(logout))
|
||||
.route("/me", get(me))
|
||||
.route("/join-networks", get(join_networks))
|
||||
.route("/join-token", post(create_join_token))
|
||||
.route("/providers", get(list_providers))
|
||||
.route("/oidc/:provider/login", get(oidc_login))
|
||||
.route("/oidc/:provider/callback", get(oidc_callback))
|
||||
|
|
@ -109,17 +169,8 @@ async fn login(
|
|||
let cookie = build_cookie(&state, token);
|
||||
let jar = jar.add(cookie);
|
||||
|
||||
Ok((
|
||||
jar,
|
||||
Json(LoginResponse {
|
||||
user: UserSummary {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
super_admin: user.super_admin,
|
||||
},
|
||||
}),
|
||||
))
|
||||
let response = auth_response(&state, user).await?;
|
||||
Ok((jar, Json(response)))
|
||||
}
|
||||
|
||||
async fn logout(
|
||||
|
|
@ -136,12 +187,79 @@ async fn logout(
|
|||
Ok((jar, Json(json!({ "ok": true }))))
|
||||
}
|
||||
|
||||
async fn me(AuthUser { user }: AuthUser) -> Result<Json<UserSummary>, ApiError> {
|
||||
Ok(Json(UserSummary {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
super_admin: user.super_admin,
|
||||
async fn me(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Json<LoginResponse>, ApiError> {
|
||||
let user = authenticated_user(&state, &jar).await?;
|
||||
let response = auth_response(&state, user).await?;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
async fn join_networks(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
) -> Result<Json<Vec<JoinableNetwork>>, ApiError> {
|
||||
let user = authenticated_user(&state, &jar).await?;
|
||||
let networks = fetch_joinable_networks(&state.pool, &user).await?;
|
||||
Ok(Json(
|
||||
networks
|
||||
.into_iter()
|
||||
.map(JoinableNetwork::from_row)
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn create_join_token(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(req): Json<CreateJoinTokenRequest>,
|
||||
) -> Result<Json<CreateJoinTokenResponse>, ApiError> {
|
||||
let user = authenticated_user(&state, &jar).await?;
|
||||
let network = fetch_network_for_join(&state.pool, req.network_id).await?;
|
||||
ensure_join_token_permission(&state.pool, &user, network.id).await?;
|
||||
|
||||
let ttl_seconds = normalize_join_token_ttl(req.ttl_seconds)?;
|
||||
let uses = normalize_join_token_uses(req.uses)?;
|
||||
let tags = req.tags.unwrap_or_default();
|
||||
|
||||
let client = crate::control_plane::ControlPlaneClient::new(
|
||||
network.base_url.clone(),
|
||||
network.admin_token.clone(),
|
||||
);
|
||||
let response = client
|
||||
.create_token(
|
||||
&network.network_id,
|
||||
CpCreateTokenRequest {
|
||||
ttl_seconds,
|
||||
uses,
|
||||
tags,
|
||||
owner_user_id: Some(user.id.to_string()),
|
||||
owner_email: Some(user.email.clone()),
|
||||
owner_is_admin: Some(false),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
|
||||
|
||||
audit_log::record(
|
||||
&state.pool,
|
||||
Some(user.id),
|
||||
"token.create_self_service",
|
||||
Some("network"),
|
||||
Some(&network.id.to_string()),
|
||||
Some(serde_json::json!({
|
||||
"network_id": network.network_id,
|
||||
"owner_user_id": user.id,
|
||||
"owner_email": user.email,
|
||||
"owner_is_admin": false,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(CreateJoinTokenResponse {
|
||||
network: JoinableNetwork::from_row(network),
|
||||
token: response.token,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -327,7 +445,7 @@ async fn ensure_oidc_user(
|
|||
.map_err(|_| ApiError::Internal)?;
|
||||
|
||||
let role_id = sqlx::query_scalar::<_, Uuid>(
|
||||
"SELECT id FROM roles WHERE name = 'viewer'",
|
||||
"SELECT id FROM roles WHERE name = 'member'",
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
|
|
@ -363,6 +481,24 @@ async fn ensure_oidc_user(
|
|||
Ok(user)
|
||||
}
|
||||
|
||||
async fn auth_response(state: &AppState, user: User) -> Result<LoginResponse, ApiError> {
|
||||
let permissions = list_permissions_global(&state.pool, &user).await?;
|
||||
let console_access = user.super_admin
|
||||
|| permissions
|
||||
.iter()
|
||||
.any(|permission| permission == PERM_CONSOLE_ACCESS);
|
||||
Ok(LoginResponse {
|
||||
user: UserSummary {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
display_name: user.display_name,
|
||||
super_admin: user.super_admin,
|
||||
},
|
||||
permissions,
|
||||
console_access,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_cookie(state: &AppState, token: String) -> Cookie<'static> {
|
||||
let mut cookie = Cookie::build((SESSION_COOKIE, token))
|
||||
.http_only(true)
|
||||
|
|
@ -444,3 +580,165 @@ pub async fn ensure_bootstrap_admin(pool: &PgPool, config: &crate::config::Confi
|
|||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl JoinableNetwork {
|
||||
fn from_row(row: JoinableNetworkRow) -> Self {
|
||||
Self {
|
||||
id: row.id,
|
||||
control_plane_id: row.control_plane_id,
|
||||
control_plane_name: row.control_plane_name,
|
||||
network_id: row.network_id,
|
||||
name: row.name,
|
||||
dns_domain: row.dns_domain,
|
||||
overlay_v4: row.overlay_v4,
|
||||
overlay_v6: row.overlay_v6,
|
||||
requires_approval: row.requires_approval,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticated_user(state: &AppState, jar: &CookieJar) -> Result<User, ApiError> {
|
||||
let token = jar
|
||||
.get(SESSION_COOKIE)
|
||||
.map(Cookie::value)
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
let user = session_user(&state.pool, token)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?
|
||||
.ok_or(ApiError::Unauthorized)?;
|
||||
if user.disabled {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn fetch_joinable_networks(pool: &PgPool, user: &User) -> Result<Vec<JoinableNetworkRow>, ApiError> {
|
||||
if user.super_admin {
|
||||
return sqlx::query_as::<_, JoinableNetworkRow>(
|
||||
r#"
|
||||
SELECT n.id,
|
||||
n.control_plane_id,
|
||||
c.name AS control_plane_name,
|
||||
n.network_id,
|
||||
n.name,
|
||||
n.dns_domain,
|
||||
n.overlay_v4,
|
||||
n.overlay_v6,
|
||||
n.requires_approval,
|
||||
c.base_url,
|
||||
c.admin_token
|
||||
FROM networks n
|
||||
JOIN control_planes c ON c.id = n.control_plane_id
|
||||
ORDER BY n.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal);
|
||||
}
|
||||
|
||||
sqlx::query_as::<_, JoinableNetworkRow>(
|
||||
r#"
|
||||
SELECT n.id,
|
||||
n.control_plane_id,
|
||||
c.name AS control_plane_name,
|
||||
n.network_id,
|
||||
n.name,
|
||||
n.dns_domain,
|
||||
n.overlay_v4,
|
||||
n.overlay_v6,
|
||||
n.requires_approval,
|
||||
c.base_url,
|
||||
c.admin_token
|
||||
FROM networks n
|
||||
JOIN control_planes c ON c.id = n.control_plane_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships m
|
||||
JOIN role_permissions rp ON rp.role_id = m.role_id
|
||||
WHERE m.user_id = $1
|
||||
AND (rp.permission = $2 OR rp.permission = $3)
|
||||
AND (
|
||||
(m.scope_type = 'global' AND m.scope_id = 'global')
|
||||
OR (m.scope_type = $4 AND m.scope_id = n.id::text)
|
||||
)
|
||||
)
|
||||
ORDER BY n.created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(PERM_JOIN_TOKENS_CREATE)
|
||||
.bind(PERM_TOKENS_WRITE)
|
||||
.bind(JOIN_SCOPE_TYPE_NETWORK)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)
|
||||
}
|
||||
|
||||
async fn fetch_network_for_join(pool: &PgPool, id: Uuid) -> Result<JoinableNetworkRow, ApiError> {
|
||||
sqlx::query_as::<_, JoinableNetworkRow>(
|
||||
r#"
|
||||
SELECT n.id,
|
||||
n.control_plane_id,
|
||||
c.name AS control_plane_name,
|
||||
n.network_id,
|
||||
n.name,
|
||||
n.dns_domain,
|
||||
n.overlay_v4,
|
||||
n.overlay_v6,
|
||||
n.requires_approval,
|
||||
c.base_url,
|
||||
c.admin_token
|
||||
FROM networks n
|
||||
JOIN control_planes c ON c.id = n.control_plane_id
|
||||
WHERE n.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?
|
||||
.ok_or(ApiError::NotFound)
|
||||
}
|
||||
|
||||
async fn ensure_join_token_permission(pool: &PgPool, user: &User, network_id: Uuid) -> Result<(), ApiError> {
|
||||
let scope_id = network_id.to_string();
|
||||
match ensure_permission(
|
||||
pool,
|
||||
user,
|
||||
PERM_JOIN_TOKENS_CREATE,
|
||||
JOIN_SCOPE_TYPE_NETWORK,
|
||||
&scope_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(()),
|
||||
Err(ApiError::Forbidden) => {
|
||||
ensure_permission(pool, user, PERM_TOKENS_WRITE, JOIN_SCOPE_TYPE_NETWORK, &scope_id)
|
||||
.await
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_join_token_ttl(value: Option<u64>) -> Result<u64, ApiError> {
|
||||
let ttl = value.unwrap_or(DEFAULT_JOIN_TOKEN_TTL_SECONDS);
|
||||
if ttl == 0 || ttl > MAX_JOIN_TOKEN_TTL_SECONDS {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"ttl_seconds must be between 1 and {}",
|
||||
MAX_JOIN_TOKEN_TTL_SECONDS
|
||||
)));
|
||||
}
|
||||
Ok(ttl)
|
||||
}
|
||||
|
||||
fn normalize_join_token_uses(value: Option<u32>) -> Result<u32, ApiError> {
|
||||
let uses = value.unwrap_or(DEFAULT_JOIN_TOKEN_USES);
|
||||
if uses == 0 || uses > MAX_JOIN_TOKEN_USES {
|
||||
return Err(ApiError::BadRequest(format!(
|
||||
"uses must be between 1 and {}",
|
||||
MAX_JOIN_TOKEN_USES
|
||||
)));
|
||||
}
|
||||
Ok(uses)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ pub mod audit;
|
|||
use crate::app_state::AppState;
|
||||
use crate::auth::{session_user, SESSION_COOKIE};
|
||||
use crate::models::User;
|
||||
use crate::permissions::{ensure_permission_global, PERM_CONSOLE_ACCESS};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::http::{request::Parts, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
|
@ -70,6 +71,7 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||
if user.disabled {
|
||||
return Err(ApiError::Unauthorized);
|
||||
}
|
||||
ensure_permission_global(&state.pool, &user, PERM_CONSOLE_ACCESS).await?;
|
||||
Ok(Self { user })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ use crate::permissions::{
|
|||
PERM_NODES_WRITE, PERM_TOKENS_WRITE, PERM_AUDIT_READ,
|
||||
};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -40,6 +41,8 @@ struct NetworkSummary {
|
|||
struct CreateNetworkRequest {
|
||||
control_plane_id: Uuid,
|
||||
name: String,
|
||||
overlay_v4: Option<String>,
|
||||
overlay_v6: Option<String>,
|
||||
dns_domain: Option<String>,
|
||||
requires_approval: Option<bool>,
|
||||
key_rotation_max_age_seconds: Option<u64>,
|
||||
|
|
@ -86,7 +89,7 @@ struct NetworkAuditQuery {
|
|||
pub fn router() -> Router<crate::app_state::AppState> {
|
||||
Router::new()
|
||||
.route("/", get(list_networks).post(create_network))
|
||||
.route("/:id", get(get_network))
|
||||
.route("/:id", get(get_network).delete(delete_network))
|
||||
.route("/:id/tokens", post(create_token))
|
||||
.route("/:id/tokens/revoke", post(revoke_token))
|
||||
.route("/:id/nodes", get(list_nodes))
|
||||
|
|
@ -168,6 +171,44 @@ async fn get_network(
|
|||
}))
|
||||
}
|
||||
|
||||
async fn delete_network(
|
||||
State(state): State<crate::app_state::AppState>,
|
||||
AuthUser { user }: AuthUser,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
ensure_permission_global(&state.pool, &user, PERM_NETWORKS_WRITE).await?;
|
||||
let (network, control_plane) = fetch_network_with_cp(&state.pool, id).await?;
|
||||
let client = client_for(&control_plane);
|
||||
|
||||
// Send delete request to control plane
|
||||
client
|
||||
.delete_network(&network.network_id)
|
||||
.await
|
||||
.map_err(|_| ApiError::BadRequest("control plane request failed".to_string()))?;
|
||||
|
||||
// Delete from database
|
||||
sqlx::query("DELETE FROM networks WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&state.pool)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?;
|
||||
|
||||
audit_log::record(
|
||||
&state.pool,
|
||||
Some(user.id),
|
||||
"network.delete",
|
||||
Some("network"),
|
||||
Some(&network.id.to_string()),
|
||||
Some(serde_json::json!({
|
||||
"network_id": network.network_id,
|
||||
"control_plane_id": control_plane.id,
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn create_network(
|
||||
State(state): State<crate::app_state::AppState>,
|
||||
AuthUser { user }: AuthUser,
|
||||
|
|
@ -183,6 +224,8 @@ async fn create_network(
|
|||
let response = client
|
||||
.create_network(CpCreateNetworkRequest {
|
||||
name: req.name,
|
||||
overlay_v4: req.overlay_v4.clone(),
|
||||
overlay_v6: req.overlay_v6.clone(),
|
||||
dns_domain: req.dns_domain.clone(),
|
||||
requires_approval: req.requires_approval,
|
||||
key_rotation_max_age_seconds: req.key_rotation_max_age_seconds,
|
||||
|
|
@ -249,6 +292,9 @@ async fn create_token(
|
|||
ttl_seconds: req.ttl_seconds,
|
||||
uses: req.uses,
|
||||
tags: req.tags,
|
||||
owner_user_id: Some(user.id.to_string()),
|
||||
owner_email: Some(user.email.clone()),
|
||||
owner_is_admin: Some(true),
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ pub struct DatabaseConfig {
|
|||
pub url: String,
|
||||
#[serde(default = "default_max_connections")]
|
||||
pub max_connections: u32,
|
||||
#[serde(default = "default_disable_migration_locking")]
|
||||
pub disable_migration_locking: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
|
|
@ -80,6 +82,10 @@ fn default_max_connections() -> u32 {
|
|||
20
|
||||
}
|
||||
|
||||
fn default_disable_migration_locking() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn default_session_ttl_minutes() -> i64 {
|
||||
720
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ impl ControlPlaneClient {
|
|||
|
||||
fn auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
if let Some(token) = &self.admin_token {
|
||||
req.header("X-Lightscale-Admin-Token", token)
|
||||
req.bearer_auth(token)
|
||||
} else {
|
||||
req
|
||||
}
|
||||
|
|
@ -199,11 +199,25 @@ impl ControlPlaneClient {
|
|||
.map_err(|err| anyhow!("audit log failed: {}", err))?;
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
pub async fn delete_network(&self, network_id: &str) -> Result<()> {
|
||||
let req = self
|
||||
.client
|
||||
.delete(self.url(&format!("/v1/networks/{}", network_id)));
|
||||
self.auth_header(req)
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()
|
||||
.map_err(|err| anyhow!("delete network failed: {}", err))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CreateNetworkRequest {
|
||||
pub name: String,
|
||||
pub overlay_v4: Option<String>,
|
||||
pub overlay_v6: Option<String>,
|
||||
pub dns_domain: Option<String>,
|
||||
pub requires_approval: Option<bool>,
|
||||
pub key_rotation_max_age_seconds: Option<u64>,
|
||||
|
|
@ -236,6 +250,12 @@ pub struct EnrollmentToken {
|
|||
pub expires_at: i64,
|
||||
pub uses_left: u32,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_is_admin: bool,
|
||||
pub revoked_at: Option<i64>,
|
||||
}
|
||||
|
||||
|
|
@ -244,6 +264,12 @@ pub struct CreateTokenRequest {
|
|||
pub ttl_seconds: u64,
|
||||
pub uses: u32,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_is_admin: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
|
@ -267,6 +293,12 @@ pub struct NodeInfo {
|
|||
pub machine_public_key: String,
|
||||
pub endpoints: Vec<String>,
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub owner_is_admin: bool,
|
||||
pub routes: Vec<Route>,
|
||||
pub last_seen: i64,
|
||||
pub approved: bool,
|
||||
|
|
@ -364,3 +396,47 @@ pub struct AuditLogQuery {
|
|||
pub node_id: Option<String>,
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use reqwest::header::AUTHORIZATION;
|
||||
|
||||
#[test]
|
||||
fn auth_header_uses_bearer_token() {
|
||||
let client = ControlPlaneClient::new(
|
||||
"https://cp.example.com".to_string(),
|
||||
Some("topsecret".to_string()),
|
||||
);
|
||||
let request = client
|
||||
.auth_header(client.client.get(client.url("/healthz")))
|
||||
.build()
|
||||
.expect("request should build");
|
||||
let auth = request
|
||||
.headers()
|
||||
.get(AUTHORIZATION)
|
||||
.and_then(|value| value.to_str().ok());
|
||||
assert_eq!(auth, Some("Bearer topsecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_network_request_serializes_overlay_cidrs() {
|
||||
let request = CreateNetworkRequest {
|
||||
name: "lab".to_string(),
|
||||
overlay_v4: Some("100.120.0.0/24".to_string()),
|
||||
overlay_v6: Some("fd42:120:0::/48".to_string()),
|
||||
dns_domain: Some("lab.lightscale".to_string()),
|
||||
requires_approval: Some(true),
|
||||
key_rotation_max_age_seconds: Some(3600),
|
||||
bootstrap_token_ttl_seconds: Some(600),
|
||||
bootstrap_token_uses: Some(1),
|
||||
bootstrap_token_tags: Some(vec!["dev".to_string()]),
|
||||
};
|
||||
let json = serde_json::to_value(request).expect("request should serialize");
|
||||
assert_eq!(json.get("overlay_v4").and_then(Value::as_str), Some("100.120.0.0/24"));
|
||||
assert_eq!(
|
||||
json.get("overlay_v6").and_then(Value::as_str),
|
||||
Some("fd42:120:0::/48")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,8 @@ pub async fn connect(config: &DatabaseConfig) -> Result<PgPool, sqlx::Error> {
|
|||
.connect(&config.url)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn is_cockroach(pool: &PgPool) -> Result<bool, sqlx::Error> {
|
||||
let version: String = sqlx::query_scalar("SELECT version()").fetch_one(pool).await?;
|
||||
Ok(version.contains("CockroachDB"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,30 @@ async fn main() -> anyhow::Result<()> {
|
|||
|
||||
let config = Config::load()?;
|
||||
let pool = db::connect(&config.database).await?;
|
||||
sqlx::migrate!("./migrations").run(&pool).await?;
|
||||
|
||||
let mut disable_migration_locking = config.database.disable_migration_locking;
|
||||
if !disable_migration_locking {
|
||||
match db::is_cockroach(&pool).await {
|
||||
Ok(true) => {
|
||||
tracing::info!("detected CockroachDB, disabling SQLx migration locking");
|
||||
disable_migration_locking = true;
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = ?err,
|
||||
"failed to detect database engine, keeping migration locking enabled"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut migrator = sqlx::migrate!("./migrations");
|
||||
if disable_migration_locking {
|
||||
// CockroachDB does not implement pg_advisory_lock used by SQLx migration locking.
|
||||
migrator.set_locking(false);
|
||||
}
|
||||
migrator.run(&pool).await?;
|
||||
|
||||
ensure_bootstrap_admin(&pool, &config).await?;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ use sqlx::PgPool;
|
|||
|
||||
pub const PERM_CONTROL_PLANES_READ: &str = "control_planes:read";
|
||||
pub const PERM_CONTROL_PLANES_WRITE: &str = "control_planes:write";
|
||||
pub const PERM_CONSOLE_ACCESS: &str = "console:access";
|
||||
pub const PERM_NETWORKS_READ: &str = "networks:read";
|
||||
pub const PERM_NETWORKS_WRITE: &str = "networks:write";
|
||||
pub const PERM_NODES_READ: &str = "nodes:read";
|
||||
pub const PERM_NODES_WRITE: &str = "nodes:write";
|
||||
pub const PERM_TOKENS_WRITE: &str = "tokens:write";
|
||||
pub const PERM_JOIN_TOKENS_CREATE: &str = "join_tokens:create";
|
||||
pub const PERM_ACL_READ: &str = "acl:read";
|
||||
pub const PERM_ACL_WRITE: &str = "acl:write";
|
||||
pub const PERM_KEY_POLICY_READ: &str = "key_policy:read";
|
||||
|
|
@ -47,3 +49,33 @@ pub async fn ensure_permission(
|
|||
Err(ApiError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_permissions_global(pool: &PgPool, user: &User) -> Result<Vec<String>, ApiError> {
|
||||
if user.super_admin {
|
||||
let permissions = sqlx::query_scalar::<_, String>(
|
||||
"SELECT DISTINCT permission FROM role_permissions ORDER BY permission",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?;
|
||||
return Ok(permissions);
|
||||
}
|
||||
|
||||
let permissions = sqlx::query_scalar::<_, String>(
|
||||
"\
|
||||
SELECT DISTINCT rp.permission\n\
|
||||
FROM memberships m\n\
|
||||
JOIN role_permissions rp ON rp.role_id = m.role_id\n\
|
||||
WHERE m.user_id = $1\n\
|
||||
AND m.scope_type = 'global'\n\
|
||||
AND m.scope_id = 'global'\n\
|
||||
ORDER BY rp.permission\n\
|
||||
",
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|_| ApiError::Internal)?;
|
||||
|
||||
Ok(permissions)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ static_dir = "frontend/dist"
|
|||
[database]
|
||||
url = "postgresql://root@localhost:26257/lightscale_admin?sslmode=disable"
|
||||
max_connections = 20
|
||||
# Optional override. CockroachDB is auto-detected and migration locking is disabled automatically.
|
||||
disable_migration_locking = true
|
||||
|
||||
[auth]
|
||||
session_ttl_minutes = 720
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import {
|
|||
createNetwork,
|
||||
createToken,
|
||||
createUser,
|
||||
deleteControlPlane,
|
||||
deleteMembership,
|
||||
deleteNetwork,
|
||||
getAcl,
|
||||
getKeyPolicy,
|
||||
getMe,
|
||||
|
|
@ -42,6 +44,8 @@ import {
|
|||
type KeyPolicyResponse,
|
||||
type NetworkSummary,
|
||||
type NodeInfo,
|
||||
type AuthSession,
|
||||
type AuthUserSummary,
|
||||
type ProviderSummary,
|
||||
type RoleSummary,
|
||||
type UserDetail,
|
||||
|
|
@ -82,12 +86,12 @@ const DEFAULT_SCOPE = { scope_type: 'global', scope_id: 'global' }
|
|||
|
||||
function App() {
|
||||
const [authReady, setAuthReady] = useState(false)
|
||||
const [currentUser, setCurrentUser] = useState<UserSummary | null>(null)
|
||||
const [session, setSession] = useState<AuthSession | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getMe()
|
||||
.then((user) => setCurrentUser(user))
|
||||
.catch(() => setCurrentUser(null))
|
||||
.then((me) => setSession(me))
|
||||
.catch(() => setSession(null))
|
||||
.finally(() => setAuthReady(true))
|
||||
}, [])
|
||||
|
||||
|
|
@ -99,20 +103,46 @@ function App() {
|
|||
)
|
||||
}
|
||||
|
||||
if (!currentUser) {
|
||||
return <LoginScreen onAuthed={setCurrentUser} />
|
||||
if (!session) {
|
||||
return <LoginScreen onAuthed={setSession} />
|
||||
}
|
||||
|
||||
if (!session.console_access) {
|
||||
return (
|
||||
<div className="screen center">
|
||||
<div className="card" style={{ maxWidth: 560 }}>
|
||||
<h3>Console access is not granted</h3>
|
||||
<p className="muted">
|
||||
This account is authenticated, but it does not have the
|
||||
<code>console:access</code> permission.
|
||||
</p>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={async () => {
|
||||
await logout()
|
||||
setSession(null)
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
user={currentUser}
|
||||
onLogout={() => setCurrentUser(null)}
|
||||
onUserUpdated={setCurrentUser}
|
||||
user={session.user}
|
||||
permissions={session.permissions}
|
||||
onLogout={() => setSession(null)}
|
||||
onSessionUpdated={setSession}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
|
||||
function LoginScreen({ onAuthed }: { onAuthed: (session: AuthSession) => void }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [providers, setProviders] = useState<ProviderSummary[]>([])
|
||||
|
|
@ -131,7 +161,7 @@ function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
|
|||
setError(null)
|
||||
try {
|
||||
const response = await login(email, password)
|
||||
onAuthed(response.user)
|
||||
onAuthed(response)
|
||||
} catch (err) {
|
||||
setError((err as Error).message)
|
||||
} finally {
|
||||
|
|
@ -223,12 +253,14 @@ function LoginScreen({ onAuthed }: { onAuthed: (user: UserSummary) => void }) {
|
|||
|
||||
function AdminShell({
|
||||
user,
|
||||
permissions,
|
||||
onLogout,
|
||||
onUserUpdated,
|
||||
onSessionUpdated,
|
||||
}: {
|
||||
user: UserSummary
|
||||
user: AuthUserSummary
|
||||
permissions: string[]
|
||||
onLogout: () => void
|
||||
onUserUpdated: (user: UserSummary) => void
|
||||
onSessionUpdated: (session: AuthSession) => void
|
||||
}) {
|
||||
const [active, setActive] = useState<SectionId>('overview')
|
||||
const [banner, setBanner] = useState<BannerMessage | null>(null)
|
||||
|
|
@ -259,12 +291,85 @@ function AdminShell({
|
|||
|
||||
const selectedNodeCount = nodesResponse?.nodes.length ?? 0
|
||||
|
||||
const permissionSet = useMemo(() => new Set(permissions), [permissions])
|
||||
const hasPermission = (permission: string) =>
|
||||
user.super_admin || permissionSet.has(permission)
|
||||
|
||||
const canControlPlanesRead = hasPermission('control_planes:read')
|
||||
const canControlPlanesWrite = hasPermission('control_planes:write')
|
||||
const canNetworksRead = hasPermission('networks:read')
|
||||
const canNetworksWrite = hasPermission('networks:write')
|
||||
const canNodesRead = hasPermission('nodes:read')
|
||||
const canNodesWrite = hasPermission('nodes:write')
|
||||
const canTokensWrite = hasPermission('tokens:write')
|
||||
const canAclRead = hasPermission('acl:read')
|
||||
const canAclWrite = hasPermission('acl:write')
|
||||
const canKeyPolicyRead = hasPermission('key_policy:read')
|
||||
const canKeyPolicyWrite = hasPermission('key_policy:write')
|
||||
const canUsersRead = hasPermission('users:read')
|
||||
const canUsersWrite = hasPermission('users:write')
|
||||
const canRolesRead = hasPermission('roles:read')
|
||||
const canAuditRead = hasPermission('audit:read')
|
||||
|
||||
const visibleSections = useMemo(
|
||||
() =>
|
||||
sections.filter((section) => {
|
||||
switch (section.id) {
|
||||
case 'overview':
|
||||
return true
|
||||
case 'control-planes':
|
||||
return canControlPlanesRead
|
||||
case 'networks':
|
||||
return canNetworksRead
|
||||
case 'nodes':
|
||||
return canNodesRead
|
||||
case 'tokens':
|
||||
return canTokensWrite
|
||||
case 'acl':
|
||||
return canAclRead
|
||||
case 'key-policy':
|
||||
return canKeyPolicyRead
|
||||
case 'users':
|
||||
return canUsersRead
|
||||
case 'audit':
|
||||
return canAuditRead
|
||||
}
|
||||
}),
|
||||
[
|
||||
canAclRead,
|
||||
canAuditRead,
|
||||
canControlPlanesRead,
|
||||
canKeyPolicyRead,
|
||||
canNetworksRead,
|
||||
canNodesRead,
|
||||
canTokensWrite,
|
||||
canUsersRead,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (canControlPlanesRead) {
|
||||
refreshControlPlanes()
|
||||
}
|
||||
if (canNetworksRead) {
|
||||
refreshNetworks()
|
||||
}
|
||||
if (canRolesRead) {
|
||||
refreshRoles()
|
||||
}
|
||||
if (canUsersRead) {
|
||||
refreshUsers()
|
||||
}, [])
|
||||
}
|
||||
}, [canControlPlanesRead, canNetworksRead, canRolesRead, canUsersRead])
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleSections.some((section) => section.id === active)) {
|
||||
return
|
||||
}
|
||||
if (visibleSections.length > 0) {
|
||||
setActive(visibleSections[0].id)
|
||||
}
|
||||
}, [active, visibleSections])
|
||||
|
||||
useEffect(() => {
|
||||
if (controlPlanes.length && !selectedControlPlaneId) {
|
||||
|
|
@ -280,23 +385,35 @@ function AdminShell({
|
|||
|
||||
useEffect(() => {
|
||||
if (!selectedNetworkId) return
|
||||
if (canNodesRead) {
|
||||
listNodes(selectedNetworkId)
|
||||
.then(setNodesResponse)
|
||||
.catch((err) => handleError(err, 'error'))
|
||||
} else {
|
||||
setNodesResponse(null)
|
||||
}
|
||||
if (canAclRead) {
|
||||
getAcl(selectedNetworkId)
|
||||
.then((policy) => setAclText(JSON.stringify(policy, null, 2)))
|
||||
.catch(() => setAclText(''))
|
||||
} else {
|
||||
setAclText('')
|
||||
}
|
||||
if (canKeyPolicyRead) {
|
||||
getKeyPolicy(selectedNetworkId)
|
||||
.then(setKeyPolicy)
|
||||
.catch(() => setKeyPolicy(null))
|
||||
}, [selectedNetworkId])
|
||||
} else {
|
||||
setKeyPolicy(null)
|
||||
}
|
||||
}, [selectedNetworkId, canAclRead, canKeyPolicyRead, canNodesRead])
|
||||
|
||||
useEffect(() => {
|
||||
if (active !== 'audit') return
|
||||
if (active !== 'audit' || !canAuditRead) return
|
||||
listAdminAudit({ limit: 200 })
|
||||
.then(setAdminAudit)
|
||||
.catch((err) => handleError(err, 'error'))
|
||||
}, [active])
|
||||
}, [active, canAuditRead])
|
||||
|
||||
const refreshControlPlanes = async () => {
|
||||
try {
|
||||
|
|
@ -359,7 +476,11 @@ function AdminShell({
|
|||
const handleUpdateCurrentUser = async () => {
|
||||
try {
|
||||
const refreshed = await getMe()
|
||||
onUserUpdated(refreshed)
|
||||
if (!refreshed.console_access) {
|
||||
onLogout()
|
||||
return
|
||||
}
|
||||
onSessionUpdated(refreshed)
|
||||
} catch (err) {
|
||||
handleError(err, 'warning')
|
||||
}
|
||||
|
|
@ -412,7 +533,7 @@ function AdminShell({
|
|||
</div>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{sections.map((section) => (
|
||||
{visibleSections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
className={active === section.id ? 'active' : ''}
|
||||
|
|
@ -437,7 +558,7 @@ function AdminShell({
|
|||
<main className="main">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>{sections.find((section) => section.id === active)?.label}</h1>
|
||||
<h1>{visibleSections.find((section) => section.id === active)?.label || 'Overview'}</h1>
|
||||
<p>
|
||||
{selectedNetwork
|
||||
? `${selectedNetwork.name} · ${selectedNetwork.control_plane_name}`
|
||||
|
|
@ -501,12 +622,13 @@ function AdminShell({
|
|||
<div className="card">
|
||||
<h3>Active signals</h3>
|
||||
<p>Audit log for admin actions and control plane events.</p>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => setActive('audit')}
|
||||
>
|
||||
{canAuditRead ? (
|
||||
<button className="ghost" onClick={() => setActive('audit')}>
|
||||
View audit timeline
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted">No audit permission.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Role coverage</h3>
|
||||
|
|
@ -514,23 +636,32 @@ function AdminShell({
|
|||
{roles.length} roles configured. {users.length} users in
|
||||
directory.
|
||||
</p>
|
||||
{canUsersRead ? (
|
||||
<button className="ghost" onClick={() => setActive('users')}>
|
||||
Manage access
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted">No user-directory permission.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Network posture</h3>
|
||||
<p>Keep ACLs tight and key rotation moving.</p>
|
||||
{canAclRead ? (
|
||||
<button className="ghost" onClick={() => setActive('acl')}>
|
||||
Edit ACL
|
||||
</button>
|
||||
) : (
|
||||
<p className="muted">No ACL permission.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{active === 'control-planes' && (
|
||||
{active === 'control-planes' && canControlPlanesRead && (
|
||||
<ControlPlaneSection
|
||||
controlPlanes={controlPlanes}
|
||||
canWrite={canControlPlanesWrite}
|
||||
onCreate={async (payload) => {
|
||||
const created = await createControlPlane(payload)
|
||||
setControlPlanes((prev) => [...prev, created])
|
||||
|
|
@ -542,31 +673,41 @@ function AdminShell({
|
|||
)
|
||||
}}
|
||||
onVerify={async (id) => verifyControlPlane(id)}
|
||||
onDelete={async (id) => {
|
||||
await deleteControlPlane(id)
|
||||
setControlPlanes((prev) => prev.filter((plane) => plane.id !== id))
|
||||
}}
|
||||
onBanner={setBanner}
|
||||
/>
|
||||
)}
|
||||
|
||||
{active === 'networks' && (
|
||||
{active === 'networks' && canNetworksRead && (
|
||||
<NetworksSection
|
||||
controlPlanes={controlPlanes}
|
||||
networks={networks}
|
||||
canWrite={canNetworksWrite}
|
||||
onCreate={async (payload) => {
|
||||
const result = await createNetwork(payload)
|
||||
setNetworks((prev) => [...prev, result.network])
|
||||
setBootstrapToken(result.bootstrap_token || null)
|
||||
setSelectedNetworkId(result.network.id)
|
||||
}}
|
||||
onDelete={async (id) => {
|
||||
await deleteNetwork(id)
|
||||
setNetworks((prev) => prev.filter((network) => network.id !== id))
|
||||
}}
|
||||
onSelect={(id) => setSelectedNetworkId(id)}
|
||||
selectedNetworkId={selectedNetworkId}
|
||||
bootstrapToken={bootstrapToken}
|
||||
/>
|
||||
)}
|
||||
|
||||
{active === 'nodes' && (
|
||||
{active === 'nodes' && canNodesRead && (
|
||||
<NodesSection
|
||||
network={selectedNetwork}
|
||||
nodes={nodesResponse?.nodes || []}
|
||||
keyHistory={keyHistory}
|
||||
canWrite={canNodesWrite}
|
||||
onApprove={(nodeId) =>
|
||||
handleNodeAction(() => approveNode(selectedNetworkId, nodeId))
|
||||
}
|
||||
|
|
@ -588,7 +729,7 @@ function AdminShell({
|
|||
/>
|
||||
)}
|
||||
|
||||
{active === 'tokens' && (
|
||||
{active === 'tokens' && canTokensWrite && (
|
||||
<TokensSection
|
||||
network={selectedNetwork}
|
||||
tokenResult={tokenResult}
|
||||
|
|
@ -607,28 +748,31 @@ function AdminShell({
|
|||
/>
|
||||
)}
|
||||
|
||||
{active === 'acl' && (
|
||||
{active === 'acl' && canAclRead && (
|
||||
<AclSection
|
||||
network={selectedNetwork}
|
||||
aclText={aclText}
|
||||
canWrite={canAclWrite}
|
||||
onChange={setAclText}
|
||||
onSave={handleAclSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{active === 'key-policy' && (
|
||||
{active === 'key-policy' && canKeyPolicyRead && (
|
||||
<KeyPolicySection
|
||||
network={selectedNetwork}
|
||||
policy={keyPolicy}
|
||||
canWrite={canKeyPolicyWrite}
|
||||
onSave={handleKeyPolicySave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{active === 'users' && (
|
||||
{active === 'users' && canUsersRead && (
|
||||
<UsersSection
|
||||
users={users}
|
||||
roles={roles}
|
||||
selectedUser={selectedUser}
|
||||
canWrite={canUsersWrite}
|
||||
onSelect={handleSelectUser}
|
||||
onCreate={async (payload) => {
|
||||
const created = await createUser(payload)
|
||||
|
|
@ -641,7 +785,10 @@ function AdminShell({
|
|||
prev.map((record) => (record.id === updated.id ? updated : record)),
|
||||
)
|
||||
if (selectedUser && selectedUser.user.id === updated.id) {
|
||||
setSelectedUser({ ...selectedUser, user: updated })
|
||||
setSelectedUser({
|
||||
...selectedUser,
|
||||
user: updated,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onSetPassword={async (userId, password) => {
|
||||
|
|
@ -670,7 +817,7 @@ function AdminShell({
|
|||
/>
|
||||
)}
|
||||
|
||||
{active === 'audit' && (
|
||||
{active === 'audit' && canAuditRead && (
|
||||
<AuditSection
|
||||
controlPlanes={controlPlanes}
|
||||
adminAudit={adminAudit}
|
||||
|
|
@ -693,12 +840,15 @@ function AdminShell({
|
|||
|
||||
function ControlPlaneSection({
|
||||
controlPlanes,
|
||||
canWrite,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onVerify,
|
||||
onDelete,
|
||||
onBanner,
|
||||
}: {
|
||||
controlPlanes: ControlPlaneSummary[]
|
||||
canWrite: boolean
|
||||
onCreate: (payload: {
|
||||
name: string
|
||||
base_url: string
|
||||
|
|
@ -716,6 +866,7 @@ function ControlPlaneSection({
|
|||
},
|
||||
) => Promise<void>
|
||||
onVerify: (id: string) => Promise<{ ok: boolean; status?: number; body?: string }>
|
||||
onDelete: (id: string) => Promise<void>
|
||||
onBanner: (message: BannerMessage) => void
|
||||
}) {
|
||||
const [form, setForm] = useState({
|
||||
|
|
@ -741,6 +892,7 @@ function ControlPlaneSection({
|
|||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!canWrite) return
|
||||
setLoading(true)
|
||||
try {
|
||||
if (form.id) {
|
||||
|
|
@ -820,7 +972,7 @@ function ControlPlaneSection({
|
|||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button type="submit" disabled={loading}>
|
||||
<button type="submit" disabled={loading || !canWrite}>
|
||||
{loading ? 'Saving...' : form.id ? 'Update plane' : 'Create plane'}
|
||||
</button>
|
||||
{form.id && (
|
||||
|
|
@ -861,6 +1013,8 @@ function ControlPlaneSection({
|
|||
>
|
||||
Verify
|
||||
</button>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() =>
|
||||
|
|
@ -876,6 +1030,18 @@ function ControlPlaneSection({
|
|||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="ghost danger"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete control plane "${plane.name}"?`)) {
|
||||
onDelete(plane.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -891,16 +1057,21 @@ function ControlPlaneSection({
|
|||
function NetworksSection({
|
||||
controlPlanes,
|
||||
networks,
|
||||
canWrite,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onSelect,
|
||||
selectedNetworkId,
|
||||
bootstrapToken,
|
||||
}: {
|
||||
controlPlanes: ControlPlaneSummary[]
|
||||
networks: NetworkSummary[]
|
||||
canWrite: boolean
|
||||
onCreate: (payload: {
|
||||
control_plane_id: string
|
||||
name: string
|
||||
overlay_v4?: string
|
||||
overlay_v6?: string
|
||||
dns_domain?: string
|
||||
requires_approval?: boolean
|
||||
key_rotation_max_age_seconds?: number
|
||||
|
|
@ -908,6 +1079,7 @@ function NetworksSection({
|
|||
bootstrap_token_uses?: number
|
||||
bootstrap_token_tags?: string[]
|
||||
}) => Promise<void>
|
||||
onDelete: (id: string) => Promise<void>
|
||||
onSelect: (id: string) => void
|
||||
selectedNetworkId: string
|
||||
bootstrapToken: EnrollmentToken | null
|
||||
|
|
@ -915,6 +1087,8 @@ function NetworksSection({
|
|||
const [form, setForm] = useState({
|
||||
control_plane_id: '',
|
||||
name: '',
|
||||
overlay_v4: '',
|
||||
overlay_v6: '',
|
||||
dns_domain: '',
|
||||
requires_approval: false,
|
||||
key_rotation_max_age_seconds: '',
|
||||
|
|
@ -925,10 +1099,13 @@ function NetworksSection({
|
|||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!canWrite) return
|
||||
if (!form.control_plane_id) return
|
||||
await onCreate({
|
||||
control_plane_id: form.control_plane_id,
|
||||
name: form.name,
|
||||
overlay_v4: form.overlay_v4 || undefined,
|
||||
overlay_v6: form.overlay_v6 || undefined,
|
||||
dns_domain: form.dns_domain || undefined,
|
||||
requires_approval: form.requires_approval,
|
||||
key_rotation_max_age_seconds: form.key_rotation_max_age_seconds
|
||||
|
|
@ -947,6 +1124,8 @@ function NetworksSection({
|
|||
setForm({
|
||||
control_plane_id: form.control_plane_id,
|
||||
name: '',
|
||||
overlay_v4: '',
|
||||
overlay_v6: '',
|
||||
dns_domain: '',
|
||||
requires_approval: false,
|
||||
key_rotation_max_age_seconds: '',
|
||||
|
|
@ -986,6 +1165,28 @@ function NetworksSection({
|
|||
required
|
||||
/>
|
||||
</label>
|
||||
<div className="inline">
|
||||
<label>
|
||||
<span>Overlay IPv4 CIDR</span>
|
||||
<input
|
||||
value={form.overlay_v4}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, overlay_v4: event.target.value })
|
||||
}
|
||||
placeholder="100.120.0.0/24"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Overlay IPv6 CIDR</span>
|
||||
<input
|
||||
value={form.overlay_v6}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, overlay_v6: event.target.value })
|
||||
}
|
||||
placeholder="fd42:120:0::/48"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>DNS domain</span>
|
||||
<input
|
||||
|
|
@ -1056,7 +1257,7 @@ function NetworksSection({
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Create network</button>
|
||||
<button type="submit" disabled={!canWrite}>Create network</button>
|
||||
</form>
|
||||
|
||||
{bootstrapToken && (
|
||||
|
|
@ -1074,15 +1275,17 @@ function NetworksSection({
|
|||
<h3>Known networks</h3>
|
||||
<div className="list">
|
||||
{networks.map((network, index) => (
|
||||
<button
|
||||
<div
|
||||
key={network.id}
|
||||
className={`list-row selectable ${
|
||||
className={`list-row ${
|
||||
network.id === selectedNetworkId ? 'active' : ''
|
||||
}`}
|
||||
style={{ animationDelay: `${index * 40}ms` }}
|
||||
>
|
||||
<div
|
||||
className="selectable-area"
|
||||
onClick={() => onSelect(network.id)}
|
||||
>
|
||||
<div>
|
||||
<strong>{network.name}</strong>
|
||||
<p>{network.network_id}</p>
|
||||
<span className="tag">{network.control_plane_name}</span>
|
||||
|
|
@ -1091,7 +1294,21 @@ function NetworksSection({
|
|||
)}
|
||||
</div>
|
||||
<div className="mono">{network.overlay_v4 || '-'}</div>
|
||||
<div className="button-row">
|
||||
{canWrite && (
|
||||
<button
|
||||
className="ghost danger"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete network "${network.name}"?`)) {
|
||||
onDelete(network.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{networks.length === 0 && <p className="muted">No networks yet.</p>}
|
||||
</div>
|
||||
|
|
@ -1104,6 +1321,7 @@ function NodesSection({
|
|||
network,
|
||||
nodes,
|
||||
keyHistory,
|
||||
canWrite,
|
||||
onApprove,
|
||||
onRevoke,
|
||||
onRotate,
|
||||
|
|
@ -1112,6 +1330,7 @@ function NodesSection({
|
|||
network: NetworkSummary | null
|
||||
nodes: NodeInfo[]
|
||||
keyHistory: KeyHistoryResponse | null
|
||||
canWrite: boolean
|
||||
onApprove: (nodeId: string) => void
|
||||
onRevoke: (nodeId: string) => void
|
||||
onRotate: (nodeId: string) => void
|
||||
|
|
@ -1137,25 +1356,29 @@ function NodesSection({
|
|||
) : (
|
||||
<span className="tag warning">pending</span>
|
||||
)}
|
||||
{node.owner_is_admin && <span className="tag">admin-issued</span>}
|
||||
{node.revoked && <span className="tag danger">revoked</span>}
|
||||
{node.key_rotation_required && (
|
||||
<span className="tag warning">rotate keys</span>
|
||||
)}
|
||||
</div>
|
||||
{node.owner_email && <small>Owner: {node.owner_email}</small>}
|
||||
<small>Last seen: {formatEpoch(node.last_seen)}</small>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{!node.approved && !node.revoked && (
|
||||
{canWrite && !node.approved && !node.revoked && (
|
||||
<button onClick={() => onApprove(node.id)}>Approve</button>
|
||||
)}
|
||||
{!node.revoked && (
|
||||
{canWrite && !node.revoked && (
|
||||
<button className="ghost" onClick={() => onRevoke(node.id)}>
|
||||
Revoke
|
||||
</button>
|
||||
)}
|
||||
{canWrite && (
|
||||
<button className="ghost" onClick={() => onRotate(node.id)}>
|
||||
Rotate keys
|
||||
</button>
|
||||
)}
|
||||
<button className="ghost" onClick={() => onViewKeys(node.id)}>
|
||||
View keys
|
||||
</button>
|
||||
|
|
@ -1267,6 +1490,14 @@ function TokensSection({
|
|||
<span>Uses left: {tokenResult.token.uses_left}</span>
|
||||
<span>Expires: {formatEpoch(tokenResult.token.expires_at)}</span>
|
||||
</div>
|
||||
{(tokenResult.token.owner_email || tokenResult.token.owner_is_admin) && (
|
||||
<div className="meta">
|
||||
{tokenResult.token.owner_email && (
|
||||
<span>Owner: {tokenResult.token.owner_email}</span>
|
||||
)}
|
||||
{tokenResult.token.owner_is_admin && <span>Privilege: admin</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1305,11 +1536,13 @@ function TokensSection({
|
|||
function AclSection({
|
||||
network,
|
||||
aclText,
|
||||
canWrite,
|
||||
onChange,
|
||||
onSave,
|
||||
}: {
|
||||
network: NetworkSummary | null
|
||||
aclText: string
|
||||
canWrite: boolean
|
||||
onChange: (value: string) => void
|
||||
onSave: () => void
|
||||
}) {
|
||||
|
|
@ -1320,9 +1553,15 @@ function AclSection({
|
|||
return (
|
||||
<div className="card">
|
||||
<h3>ACL policy for {network.name}</h3>
|
||||
<textarea value={aclText} onChange={(event) => onChange(event.target.value)} />
|
||||
<textarea
|
||||
value={aclText}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
<div className="button-row">
|
||||
<button onClick={onSave}>Save policy</button>
|
||||
<button onClick={onSave} disabled={!canWrite}>
|
||||
Save policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1331,10 +1570,12 @@ function AclSection({
|
|||
function KeyPolicySection({
|
||||
network,
|
||||
policy,
|
||||
canWrite,
|
||||
onSave,
|
||||
}: {
|
||||
network: NetworkSummary | null
|
||||
policy: KeyPolicyResponse | null
|
||||
canWrite: boolean
|
||||
onSave: (maxAge: number | null) => void
|
||||
}) {
|
||||
const [maxAge, setMaxAge] = useState('')
|
||||
|
|
@ -1360,10 +1601,14 @@ function KeyPolicySection({
|
|||
type="number"
|
||||
value={maxAge}
|
||||
onChange={(event) => setMaxAge(event.target.value)}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button onClick={() => onSave(maxAge ? Number(maxAge) : null)}>
|
||||
<button
|
||||
onClick={() => onSave(maxAge ? Number(maxAge) : null)}
|
||||
disabled={!canWrite}
|
||||
>
|
||||
Save policy
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -1375,6 +1620,7 @@ function UsersSection({
|
|||
users,
|
||||
roles,
|
||||
selectedUser,
|
||||
canWrite,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
|
|
@ -1385,6 +1631,7 @@ function UsersSection({
|
|||
users: UserSummary[]
|
||||
roles: RoleSummary[]
|
||||
selectedUser: UserDetail | null
|
||||
canWrite: boolean
|
||||
onSelect: (id: string) => void
|
||||
onCreate: (payload: {
|
||||
email: string
|
||||
|
|
@ -1446,6 +1693,7 @@ function UsersSection({
|
|||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!canWrite) return
|
||||
onCreate({
|
||||
email: createForm.email,
|
||||
display_name: createForm.display_name || undefined,
|
||||
|
|
@ -1519,7 +1767,8 @@ function UsersSection({
|
|||
/>
|
||||
<span>Super admin</span>
|
||||
</label>
|
||||
<button type="submit">Create user</button>
|
||||
<button type="submit" disabled={!canWrite}>Create user</button>
|
||||
{!canWrite && <p className="muted">Read-only.</p>}
|
||||
</form>
|
||||
</div>
|
||||
<div className="card">
|
||||
|
|
@ -1583,6 +1832,7 @@ function UsersSection({
|
|||
<span>Super admin</span>
|
||||
</label>
|
||||
<button
|
||||
disabled={!canWrite}
|
||||
onClick={() =>
|
||||
onUpdate(selectedUser.user.id, {
|
||||
display_name: updateForm.display_name,
|
||||
|
|
@ -1604,6 +1854,7 @@ function UsersSection({
|
|||
</label>
|
||||
<button
|
||||
className="ghost"
|
||||
disabled={!canWrite}
|
||||
onClick={() => {
|
||||
if (!password) return
|
||||
onSetPassword(selectedUser.user.id, password)
|
||||
|
|
@ -1632,6 +1883,7 @@ function UsersSection({
|
|||
</div>
|
||||
<button
|
||||
className="ghost"
|
||||
disabled={!canWrite}
|
||||
onClick={() =>
|
||||
onDeleteMembership(selectedUser.user.id, membership.id)
|
||||
}
|
||||
|
|
@ -1647,6 +1899,7 @@ function UsersSection({
|
|||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!canWrite) return
|
||||
if (!membershipForm.role_id) return
|
||||
onAddMembership(selectedUser.user.id, membershipForm)
|
||||
}}
|
||||
|
|
@ -1697,7 +1950,7 @@ function UsersSection({
|
|||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit">Add membership</button>
|
||||
<button type="submit" disabled={!canWrite}>Add membership</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -35,6 +35,19 @@ export type UserSummary = {
|
|||
updated_at: string;
|
||||
};
|
||||
|
||||
export type AuthUserSummary = {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string | null;
|
||||
super_admin: boolean;
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
user: AuthUserSummary;
|
||||
permissions: string[];
|
||||
console_access: boolean;
|
||||
};
|
||||
|
||||
export type MembershipSummary = {
|
||||
id: string;
|
||||
role_id: string;
|
||||
|
|
@ -97,6 +110,9 @@ export type NodeInfo = {
|
|||
machine_public_key: string;
|
||||
endpoints: string[];
|
||||
tags: string[];
|
||||
owner_user_id?: string | null;
|
||||
owner_email?: string | null;
|
||||
owner_is_admin?: boolean;
|
||||
routes: RouteInfo[];
|
||||
last_seen: number;
|
||||
approved: boolean;
|
||||
|
|
@ -113,6 +129,9 @@ export type EnrollmentToken = {
|
|||
expires_at: number;
|
||||
uses_left: number;
|
||||
tags: string[];
|
||||
owner_user_id?: string | null;
|
||||
owner_email?: string | null;
|
||||
owner_is_admin?: boolean;
|
||||
revoked_at?: number | null;
|
||||
};
|
||||
|
||||
|
|
@ -188,11 +207,11 @@ export type ProviderSummary = {
|
|||
};
|
||||
|
||||
export async function getMe() {
|
||||
return request<UserSummary>('/auth/me');
|
||||
return request<AuthSession>('/auth/me');
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
return request<{ user: UserSummary }>('/auth/login', {
|
||||
return request<AuthSession>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
|
@ -249,6 +268,8 @@ export async function listNetworks() {
|
|||
export async function createNetwork(payload: {
|
||||
control_plane_id: string;
|
||||
name: string;
|
||||
overlay_v4?: string;
|
||||
overlay_v6?: string;
|
||||
dns_domain?: string;
|
||||
requires_approval?: boolean;
|
||||
key_rotation_max_age_seconds?: number;
|
||||
|
|
@ -419,3 +440,23 @@ export async function listControlPlaneAudit(
|
|||
`/audit/control-planes/${controlPlaneId}${query ? `?${query}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteNetwork(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/networks/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete network: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteControlPlane(id: string): Promise<void> {
|
||||
const res = await fetch(`${API_BASE}/control-planes/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to delete control plane: ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue