Compare commits
11 Commits
feat/users
...
feat/app-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6fc6e6a0e | ||
|
|
66b84abf6d | ||
|
|
a9fc838577 | ||
|
|
2948875a96 | ||
|
|
b7175cc581 | ||
|
|
d40ebf65a2 | ||
|
|
816a13b920 | ||
|
|
248571dcde | ||
|
|
85bbabcbdf | ||
|
|
1314420fca | ||
|
|
33697a2766 |
331
crates/manager-core/src/app_members_api.rs
Normal file
331
crates/manager-core/src/app_members_api.rs
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
|
||||||
|
//! `app_members` table (blueprint §11.6).
|
||||||
|
//!
|
||||||
|
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
|
||||||
|
//! resolving the app from `id_or_slug`. Editors and viewers receive
|
||||||
|
//! 403 from list and never see the dashboard's Members tab.
|
||||||
|
//!
|
||||||
|
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
|
||||||
|
//! user_id)` returns 409 rather than upsert-200, so the UI can show
|
||||||
|
//! "already a member — promote / demote them instead" cleanly. Role
|
||||||
|
//! changes go through PATCH.
|
||||||
|
//!
|
||||||
|
//! No last-app-admin guard: owners always implicitly satisfy
|
||||||
|
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
|
||||||
|
//! final explicit `app_admin` membership cannot orphan an app.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Json, Response};
|
||||||
|
use axum::routing::{get, patch};
|
||||||
|
use axum::{Extension, Router};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::json;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||||
|
use crate::app_members_repo::{
|
||||||
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
};
|
||||||
|
use crate::app_repo::AppRepository;
|
||||||
|
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||||
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppMembersState {
|
||||||
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
pub users: Arc<dyn AdminUserRepository>,
|
||||||
|
pub members: Arc<dyn AppMembersRepository>,
|
||||||
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn app_members_router(state: AppMembersState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members",
|
||||||
|
get(list_members).post(grant_member),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/apps/{id_or_slug}/members/{user_id}",
|
||||||
|
patch(patch_member).delete(remove_member),
|
||||||
|
)
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// DTOs
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct AppMemberDto {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AppMembershipDetail> for AppMemberDto {
|
||||||
|
fn from(d: AppMembershipDetail) -> Self {
|
||||||
|
Self {
|
||||||
|
user_id: d.user_id,
|
||||||
|
username: d.username,
|
||||||
|
email: d.email,
|
||||||
|
instance_role: d.instance_role,
|
||||||
|
is_active: d.is_active,
|
||||||
|
role: d.role,
|
||||||
|
created_at: d.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
|
||||||
|
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
|
||||||
|
/// POST/PATCH at the cost of trusting the two inputs reference the
|
||||||
|
/// same user_id — caller's responsibility.
|
||||||
|
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
|
||||||
|
AppMemberDto {
|
||||||
|
user_id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
is_active: user.is_active,
|
||||||
|
role: membership.role,
|
||||||
|
created_at: membership.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GrantMemberRequest {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct PatchMemberRequest {
|
||||||
|
pub role: AppRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Handlers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
let rows = s.members.list_for_app_enriched(app.id).await?;
|
||||||
|
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grant_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(input): Json<GrantMemberRequest>,
|
||||||
|
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(input.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
|
||||||
|
validate_grant_target(&user)?;
|
||||||
|
|
||||||
|
// Atomic insert — if a row already exists, returns None and we 409.
|
||||||
|
// Avoids the find-then-upsert race where two concurrent POSTs would
|
||||||
|
// both pass the existence check and the second `upsert` would
|
||||||
|
// silently rewrite the role.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.try_insert(app.id, user.id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppMembersApiError::AlreadyMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
})?;
|
||||||
|
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
Json(input): Json<PatchMemberRequest>,
|
||||||
|
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
|
||||||
|
let user_id = AdminUserId::from(user_id);
|
||||||
|
let user = s
|
||||||
|
.users
|
||||||
|
.get(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
|
||||||
|
|
||||||
|
// Atomic update — returns None if no row exists, so 404 is decided
|
||||||
|
// by the same statement that does the write. Eliminates the
|
||||||
|
// find-then-upsert race where a concurrent DELETE between the two
|
||||||
|
// calls would let PATCH silently re-create the row.
|
||||||
|
let row = s
|
||||||
|
.members
|
||||||
|
.update_role(app.id, user_id, input.role)
|
||||||
|
.await?
|
||||||
|
.ok_or(AppMembersApiError::MembershipNotFound)?;
|
||||||
|
Ok(Json(compose_dto(user, row)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
State(s): State<AppMembersState>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||||
|
) -> Result<StatusCode, AppMembersApiError> {
|
||||||
|
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||||
|
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||||
|
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Validation + helpers
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
|
||||||
|
if !user.is_active {
|
||||||
|
return Err(AppMembersApiError::TargetInactive {
|
||||||
|
username: user.username.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if user.instance_role != InstanceRole::Member {
|
||||||
|
return Err(AppMembersApiError::TargetNotMember {
|
||||||
|
username: user.username.clone(),
|
||||||
|
instance_role: user.instance_role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<picloud_shared::App, AppMembersApiError> {
|
||||||
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
|
.await?
|
||||||
|
.map(|l| l.app)
|
||||||
|
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Errors
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum AppMembersApiError {
|
||||||
|
#[error("app not found: {0}")]
|
||||||
|
AppNotFound(String),
|
||||||
|
|
||||||
|
#[error("user not found: {0}")]
|
||||||
|
UserNotFound(AdminUserId),
|
||||||
|
|
||||||
|
#[error("no membership exists for this user on this app")]
|
||||||
|
MembershipNotFound,
|
||||||
|
|
||||||
|
#[error("{username} is already a member of this app — use PATCH to change their role")]
|
||||||
|
AlreadyMember { username: String },
|
||||||
|
|
||||||
|
#[error("{username} is deactivated and cannot be added as a member")]
|
||||||
|
TargetInactive { username: String },
|
||||||
|
|
||||||
|
#[error(
|
||||||
|
"{username} has instance_role {instance_role:?} and already has implicit access \
|
||||||
|
on every app — no explicit membership needed"
|
||||||
|
)]
|
||||||
|
TargetNotMember {
|
||||||
|
username: String,
|
||||||
|
instance_role: InstanceRole,
|
||||||
|
},
|
||||||
|
|
||||||
|
#[error("forbidden")]
|
||||||
|
Forbidden,
|
||||||
|
|
||||||
|
#[error("authorization repo error: {0}")]
|
||||||
|
AuthzRepo(String),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Members(#[from] AppMembersRepositoryError),
|
||||||
|
|
||||||
|
#[error("user repository error: {0}")]
|
||||||
|
Users(#[from] AdminUserRepositoryError),
|
||||||
|
|
||||||
|
#[error("repository error: {0}")]
|
||||||
|
Apps(#[from] ScriptRepositoryError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AuthzDenied> for AppMembersApiError {
|
||||||
|
fn from(d: AuthzDenied) -> Self {
|
||||||
|
match d {
|
||||||
|
AuthzDenied::Denied => Self::Forbidden,
|
||||||
|
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntoResponse for AppMembersApiError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, body) = match &self {
|
||||||
|
Self::AppNotFound(_)
|
||||||
|
| Self::UserNotFound(_)
|
||||||
|
| Self::MembershipNotFound
|
||||||
|
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
|
||||||
|
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
|
||||||
|
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||||
|
}
|
||||||
|
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
json!({ "error": self.to_string() }),
|
||||||
|
),
|
||||||
|
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||||
|
Self::AuthzRepo(e) => {
|
||||||
|
tracing::error!(error = %e, "app members authz repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Members(e) => {
|
||||||
|
tracing::error!(error = %e, "app members repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Users(e) => {
|
||||||
|
tracing::error!(error = %e, "admin users repo error");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Self::Apps(ScriptRepositoryError::Db(e)) => {
|
||||||
|
tracing::error!(error = %e, "apps repo error in app_members");
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
json!({ "error": "internal error" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
(status, Json(body)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{AdminUserId, AppId, AppRole};
|
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::authz::{AuthzError, AuthzRepo};
|
use crate::authz::{AuthzError, AuthzRepo};
|
||||||
@@ -36,6 +36,20 @@ pub struct AppMembershipRow {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `app_members` row joined with `admin_users` so the dashboard's
|
||||||
|
/// Members tab can render usernames / emails / status without an N+1
|
||||||
|
/// fetch per row. Drives `GET /apps/{id}/members`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppMembershipDetail {
|
||||||
|
pub user_id: AdminUserId,
|
||||||
|
pub username: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub is_active: bool,
|
||||||
|
pub role: AppRole,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AppMembersRepository: Send + Sync {
|
pub trait AppMembersRepository: Send + Sync {
|
||||||
/// Single (user, app) lookup. Returns `None` for non-members and
|
/// Single (user, app) lookup. Returns `None` for non-members and
|
||||||
@@ -55,6 +69,27 @@ pub trait AppMembersRepository: Send + Sync {
|
|||||||
role: AppRole,
|
role: AppRole,
|
||||||
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic insert. Returns `Some(row)` on success, `None` if a
|
||||||
|
/// membership already exists. Lets the HTTP handler return 409
|
||||||
|
/// without a separate `find` round-trip (no TOCTOU between check
|
||||||
|
/// and insert).
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Atomic role update. Returns `Some(row)` on success, `None` if no
|
||||||
|
/// membership row exists. Lets PATCH return 404 without a separate
|
||||||
|
/// `find` round-trip (no TOCTOU between check and update).
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
||||||
/// the user wasn't a member, which is the desired post-condition.
|
/// the user wasn't a member, which is the desired post-condition.
|
||||||
async fn remove(
|
async fn remove(
|
||||||
@@ -78,6 +113,14 @@ pub trait AppMembersRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||||
|
|
||||||
|
/// Like `list_for_app` but joined with `admin_users` so the
|
||||||
|
/// dashboard can render member rows in one round-trip. Ordered by
|
||||||
|
/// username for a stable list.
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresAppMembersRepository {
|
pub struct PostgresAppMembersRepository {
|
||||||
@@ -143,6 +186,45 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn try_insert(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"INSERT INTO app_members (app_id, user_id, role) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT (app_id, user_id) DO NOTHING \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.bind(role.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_role(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||||
|
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||||
|
"UPDATE app_members SET role = $1 \
|
||||||
|
WHERE app_id = $2 AND user_id = $3 \
|
||||||
|
RETURNING app_id, user_id, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(role.as_str())
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.bind(user_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_for_user(
|
async fn list_for_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: AdminUserId,
|
user_id: AdminUserId,
|
||||||
@@ -172,6 +254,24 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_for_app_enriched(
|
||||||
|
&self,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
|
||||||
|
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
|
||||||
|
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||||
|
am.role, am.created_at \
|
||||||
|
FROM app_members am \
|
||||||
|
JOIN admin_users au ON au.id = am.user_id \
|
||||||
|
WHERE am.app_id = $1 \
|
||||||
|
ORDER BY au.username",
|
||||||
|
)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
||||||
@@ -210,3 +310,31 @@ impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AppMembershipDetailRecord {
|
||||||
|
id: uuid::Uuid,
|
||||||
|
username: String,
|
||||||
|
email: Option<String>,
|
||||||
|
instance_role: String,
|
||||||
|
is_active: bool,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
|
||||||
|
type Error = AppMembersRepositoryError;
|
||||||
|
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
user_id: r.id.into(),
|
||||||
|
username: r.username,
|
||||||
|
email: r.email,
|
||||||
|
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||||
|
is_active: r.is_active,
|
||||||
|
role: AppRole::from_db_str(&r.role)
|
||||||
|
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use picloud_shared::{AdminUserId, App, AppId};
|
use picloud_shared::{AdminUserId, App, AppId};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
|
|
||||||
@@ -20,6 +21,32 @@ pub struct AppLookup {
|
|||||||
pub redirected: bool,
|
pub redirected: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
|
||||||
|
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
|
||||||
|
/// fall through to `app_slug_history` and set `redirected: true` when
|
||||||
|
/// they hit it.
|
||||||
|
///
|
||||||
|
/// Returns `Ok(None)` when nothing matches — callers map that to their
|
||||||
|
/// own not-found error variant.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates any underlying repository error.
|
||||||
|
pub async fn resolve_app(
|
||||||
|
apps: &dyn AppRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||||
|
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||||
|
return Ok(apps
|
||||||
|
.get_by_id(AppId::from(uuid))
|
||||||
|
.await?
|
||||||
|
.map(|app| AppLookup {
|
||||||
|
app,
|
||||||
|
redirected: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
apps.get_by_slug_or_history(ident).await
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AppRepository: Send + Sync {
|
pub trait AppRepository: Send + Sync {
|
||||||
/// Every app on the instance. For owner/admin callers — `member`
|
/// Every app on the instance. For owner/admin callers — `member`
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ use axum::response::{IntoResponse, Json, Response};
|
|||||||
use axum::routing::{delete, get, post};
|
use axum::routing::{delete, get, post};
|
||||||
use axum::{Extension, Router};
|
use axum::{Extension, Router};
|
||||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||||
use picloud_shared::{App, AppDomain, AppId, InstanceRole, Principal};
|
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||||
use crate::repo::ScriptRepositoryError;
|
use crate::repo::ScriptRepositoryError;
|
||||||
use crate::route_repo::RouteRepository;
|
use crate::route_repo::RouteRepository;
|
||||||
|
|
||||||
@@ -141,6 +141,12 @@ pub struct AppLookupResponse {
|
|||||||
/// at the live slug so dashboards can redirect.
|
/// at the live slug so dashboards can redirect.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub redirect_to: Option<String>,
|
pub redirect_to: Option<String>,
|
||||||
|
/// The caller's role on this app, used by the dashboard to decide
|
||||||
|
/// whether to render admin-only surfaces (Members tab, settings).
|
||||||
|
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
|
||||||
|
/// per blueprint §11.6); `Member` carries its explicit
|
||||||
|
/// `app_members.role`.
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -209,12 +215,31 @@ async fn get_app(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
|
||||||
Ok(Json(AppLookupResponse {
|
Ok(Json(AppLookupResponse {
|
||||||
app: lookup.app,
|
app: lookup.app,
|
||||||
redirect_to,
|
redirect_to,
|
||||||
|
my_role,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||||
|
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||||
|
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||||
|
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
|
||||||
|
/// everywhere; `Member` consults `app_members`.
|
||||||
|
async fn compute_my_role(
|
||||||
|
authz: &dyn AuthzRepo,
|
||||||
|
principal: &Principal,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Option<AppRole>, AppsApiError> {
|
||||||
|
match principal.instance_role {
|
||||||
|
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
|
||||||
|
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
|
||||||
|
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn patch_app(
|
async fn patch_app(
|
||||||
State(s): State<AppsState>,
|
State(s): State<AppsState>,
|
||||||
Extension(principal): Extension<Principal>,
|
Extension(principal): Extension<Principal>,
|
||||||
@@ -429,16 +454,7 @@ async fn resolve_app(
|
|||||||
apps: &dyn AppRepository,
|
apps: &dyn AppRepository,
|
||||||
ident: &str,
|
ident: &str,
|
||||||
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||||
if let Ok(uuid) = ident.parse::<Uuid>() {
|
crate::app_repo::resolve_app(apps, ident)
|
||||||
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
|
||||||
return Ok(crate::app_repo::AppLookup {
|
|
||||||
app,
|
|
||||||
redirected: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
|
||||||
}
|
|
||||||
apps.get_by_slug_or_history(ident)
|
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||||
}
|
}
|
||||||
@@ -546,6 +562,12 @@ impl From<AuthzDenied> for AppsApiError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<AuthzError> for AppsApiError {
|
||||||
|
fn from(e: AuthzError) -> Self {
|
||||||
|
Self::AuthzRepo(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl IntoResponse for AppsApiError {
|
impl IntoResponse for AppsApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let (status, body) = match &self {
|
let (status, body) = match &self {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod api_key_repo;
|
|||||||
pub mod api_keys_api;
|
pub mod api_keys_api;
|
||||||
pub mod app_bootstrap;
|
pub mod app_bootstrap;
|
||||||
pub mod app_domain_repo;
|
pub mod app_domain_repo;
|
||||||
|
pub mod app_members_api;
|
||||||
pub mod app_members_repo;
|
pub mod app_members_repo;
|
||||||
pub mod app_repo;
|
pub mod app_repo;
|
||||||
pub mod apps_api;
|
pub mod apps_api;
|
||||||
@@ -45,10 +46,12 @@ pub use api_key_repo::{
|
|||||||
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||||
|
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
|
||||||
pub use app_members_repo::{
|
pub use app_members_repo::{
|
||||||
AppMembersRepository, AppMembersRepositoryError, AppMembershipRow, PostgresAppMembersRepository,
|
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||||
|
PostgresAppMembersRepository,
|
||||||
};
|
};
|
||||||
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
||||||
pub use apps_api::{apps_router, AppsState};
|
pub use apps_api::{apps_router, AppsState};
|
||||||
pub use auth_api::auth_router;
|
pub use auth_api::auth_router;
|
||||||
pub use auth_bootstrap::{
|
pub use auth_bootstrap::{
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ use axum::middleware::from_fn_with_state;
|
|||||||
use axum::{routing::get, Json, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use picloud_executor_core::{Engine, Limits};
|
use picloud_executor_core::{Engine, Limits};
|
||||||
use picloud_manager_core::{
|
use picloud_manager_core::{
|
||||||
admin_router, admins_router, api_keys_router, apps_api, apps_router, auth_router,
|
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||||
compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository,
|
auth_router, compile_routes, migrations, require_authenticated, route_admin_router,
|
||||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository,
|
||||||
AppDomainRepository, AppRepository, AppsState, AuthState, AuthzRepo,
|
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository,
|
||||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository,
|
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||||
PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository,
|
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
||||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
||||||
|
RouteRepository, SandboxCeiling,
|
||||||
};
|
};
|
||||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||||
use picloud_orchestrator_core::{
|
use picloud_orchestrator_core::{
|
||||||
@@ -79,6 +80,7 @@ fn read_session_ttl() -> Duration {
|
|||||||
/// the `require_admin` middleware. The data plane
|
/// the `require_admin` middleware. The data plane
|
||||||
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
||||||
/// `/version`) stays open — it's the public ingress for user scripts.
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||||
let engine = Arc::new(Engine::new(Limits::default()));
|
let engine = Arc::new(Engine::new(Limits::default()));
|
||||||
|
|
||||||
@@ -89,9 +91,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||||
// Authz: app_members repo doubles as the AuthzRepo impl for the
|
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||||
// per-handler capability checks introduced in Phase 3.5.
|
// (CRUD over the table) and `AuthzRepo` (single-row membership lookup
|
||||||
let authz: Arc<dyn AuthzRepo> = Arc::new(PostgresAppMembersRepository::new(pool));
|
// for capability checks). Construct it once and clone the Arc into
|
||||||
|
// both trait views — same allocation, two vtables.
|
||||||
|
let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool));
|
||||||
|
let members: Arc<dyn AppMembersRepository> = members_concrete.clone();
|
||||||
|
let authz: Arc<dyn AuthzRepo> = members_concrete;
|
||||||
|
|
||||||
// Compile the routes table once at startup; admin writes refresh it.
|
// Compile the routes table once at startup; admin writes refresh it.
|
||||||
let route_table = Arc::new(RouteTable::new());
|
let route_table = Arc::new(RouteTable::new());
|
||||||
@@ -159,9 +165,15 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
ttl: auth.ttl,
|
ttl: auth.ttl,
|
||||||
};
|
};
|
||||||
let admins_state = AdminsState {
|
let admins_state = AdminsState {
|
||||||
users: auth.users,
|
users: auth.users.clone(),
|
||||||
sessions: auth.sessions,
|
sessions: auth.sessions,
|
||||||
keys: auth.keys.clone(),
|
keys: auth.keys.clone(),
|
||||||
|
authz: authz.clone(),
|
||||||
|
};
|
||||||
|
let app_members_state = AppMembersState {
|
||||||
|
apps: apps_state.apps.clone(),
|
||||||
|
users: auth.users,
|
||||||
|
members,
|
||||||
authz,
|
authz,
|
||||||
};
|
};
|
||||||
let api_keys_state = ApiKeysState { keys: auth.keys };
|
let api_keys_state = ApiKeysState { keys: auth.keys };
|
||||||
@@ -177,6 +189,7 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|||||||
.merge(route_admin_router(route_admin))
|
.merge(route_admin_router(route_admin))
|
||||||
.merge(admins_router(admins_state))
|
.merge(admins_router(admins_state))
|
||||||
.merge(apps_router(apps_state))
|
.merge(apps_router(apps_state))
|
||||||
|
.merge(app_members_router(app_members_state))
|
||||||
.merge(api_keys_router(api_keys_state))
|
.merge(api_keys_router(api_keys_state))
|
||||||
.layer(from_fn_with_state(
|
.layer(from_fn_with_state(
|
||||||
auth_state.clone(),
|
auth_state.clone(),
|
||||||
|
|||||||
@@ -160,6 +160,72 @@ async fn mint_key(server: &TestServer, cred_token: &str, body: Value) -> axum_te
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- app members helpers ----------------------------------------------------
|
||||||
|
|
||||||
|
async fn list_members(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.get(&format!("/api/v1/admin/apps/{app_ident}/members"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_member(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.post(&format!("/api/v1/admin/apps/{app_ident}/members"))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({ "user_id": user_id, "role": role.as_str() }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch_member_role(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
role: AppRole,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.patch(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.json(&json!({ "role": role.as_str() }))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_member(
|
||||||
|
server: &TestServer,
|
||||||
|
token: &str,
|
||||||
|
app_ident: &str,
|
||||||
|
user_id: AdminUserId,
|
||||||
|
) -> axum_test::TestResponse {
|
||||||
|
server
|
||||||
|
.delete(&format!("/api/v1/admin/apps/{app_ident}/members/{user_id}",))
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Direct-DB inactive-user seed — the create-then-deactivate dance
|
||||||
|
/// through the API is more ceremony than the test needs.
|
||||||
|
async fn seed_inactive_user(pool: &PgPool, username: &str, password: &str) -> AdminUserId {
|
||||||
|
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||||
|
let hash = hash_password(password).expect("hash");
|
||||||
|
let row = repo
|
||||||
|
.create(username, &hash, InstanceRole::Member, None)
|
||||||
|
.await
|
||||||
|
.expect("seed user");
|
||||||
|
repo.set_active(row.id, false).await.expect("deactivate");
|
||||||
|
row.id
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// 1. Bootstrap admin → owner
|
// 1. Bootstrap admin → owner
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -645,3 +711,389 @@ async fn list_active_owners_drives_the_multi_owner_warning(pool: PgPool) {
|
|||||||
.expect("count");
|
.expect("count");
|
||||||
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
|
assert_eq!(remaining, 1, "one other owner should remain (owner2)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 12. `my_role` on GET /apps/{id_or_slug} reflects the caller's effective role
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn my_role_field_matches_caller_role(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
|
||||||
|
// Owner → implicit app_admin everywhere.
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
|
Some("app_admin"),
|
||||||
|
"owner reports app_admin"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Admin → implicit editor everywhere.
|
||||||
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
|
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {admin_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
|
Some("editor"),
|
||||||
|
"admin reports editor"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Member with explicit `viewer` membership → viewer.
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps/default")
|
||||||
|
.add_header("authorization", format!("Bearer {bob_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
r.json::<Value>()["my_role"].as_str(),
|
||||||
|
Some("viewer"),
|
||||||
|
"member with viewer row reports viewer"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// 13. App members CRUD — `/api/v1/admin/apps/{id_or_slug}/members[/{user_id}]`
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_members_includes_seeded_member(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = list_members(&s.server, &owner_token, "default").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let rows = r.json::<Vec<Value>>();
|
||||||
|
let bob_row = rows
|
||||||
|
.iter()
|
||||||
|
.find(|v| v["username"] == "bob")
|
||||||
|
.expect("bob in list");
|
||||||
|
assert_eq!(bob_row["role"], "viewer");
|
||||||
|
assert_eq!(bob_row["instance_role"], "member");
|
||||||
|
assert_eq!(bob_row["is_active"], true);
|
||||||
|
assert!(bob_row["created_at"].is_string(), "carries created_at");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn list_members_requires_app_admin(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
|
||||||
|
// Bob has explicit editor on default app — enough to read scripts,
|
||||||
|
// not enough to see the member list.
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Editor).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
let r = list_members(&s.server, &bob_token, "default").await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_creates_row(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let body = r.json::<Value>();
|
||||||
|
assert_eq!(body["username"], "bob");
|
||||||
|
assert_eq!(body["role"], "viewer");
|
||||||
|
assert_eq!(body["instance_role"], "member");
|
||||||
|
|
||||||
|
// Visible on subsequent list.
|
||||||
|
let rows = list_members(&s.server, &owner_token, "default")
|
||||||
|
.await
|
||||||
|
.json::<Vec<Value>>();
|
||||||
|
assert!(rows.iter().any(|v| v["username"] == "bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_duplicate_returns_409(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CONFLICT);
|
||||||
|
let err = r.json::<Value>()["error"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("already a member"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_inactive_user_returns_422(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_inactive_user(&s.pool, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let err = r.json::<Value>()["error"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("deactivated"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_admin_target_returns_422(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let alice = seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", alice, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
let err = r.json::<Value>()["error"]
|
||||||
|
.as_str()
|
||||||
|
.expect("error message")
|
||||||
|
.to_string();
|
||||||
|
assert!(err.contains("implicit access"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_owner_target_returns_422(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let other_owner = seed_user(&s.pool, "owner2", "ow2-pw", InstanceRole::Owner).await;
|
||||||
|
|
||||||
|
let r = add_member(
|
||||||
|
&s.server,
|
||||||
|
&owner_token,
|
||||||
|
"default",
|
||||||
|
other_owner,
|
||||||
|
AppRole::Viewer,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn patch_member_promotes_role(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(r.json::<Value>()["role"], "editor");
|
||||||
|
|
||||||
|
// Editor can now create a script (capability promotion observable
|
||||||
|
// end-to-end, not just via the role string).
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
create_script_via_api(&s.server, &bob_token, s.default_app, "bob-script").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn patch_member_without_existing_returns_404(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
|
||||||
|
// No grant yet — PATCH must 404.
|
||||||
|
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn patch_member_same_role_is_idempotent(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = patch_member_role(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert_eq!(r.json::<Value>()["role"], "viewer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_member_removes_row(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
|
||||||
|
let r = remove_member(&s.server, &owner_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let rows = list_members(&s.server, &owner_token, "default")
|
||||||
|
.await
|
||||||
|
.json::<Vec<Value>>();
|
||||||
|
assert!(rows.iter().all(|v| v["username"] != "bob"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn delete_member_missing_returns_204(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
// No grant ever happened — delete is idempotent.
|
||||||
|
|
||||||
|
let r = remove_member(&s.server, &owner_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn mutating_endpoints_require_app_admin(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::Viewer).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
let target = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
|
||||||
|
|
||||||
|
let r = add_member(&s.server, &bob_token, "default", target, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let r = patch_member_role(&s.server, &bob_token, "default", bob, AppRole::Editor).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
|
||||||
|
let r = remove_member(&s.server, &bob_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn members_endpoint_resolves_by_id_or_slug(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
let by_slug = list_members(&s.server, &owner_token, "default").await;
|
||||||
|
by_slug.assert_status_ok();
|
||||||
|
let by_id = list_members(&s.server, &owner_token, &s.default_app.to_string()).await;
|
||||||
|
by_id.assert_status_ok();
|
||||||
|
assert_eq!(
|
||||||
|
by_slug.json::<Value>(),
|
||||||
|
by_id.json::<Value>(),
|
||||||
|
"id and slug return identical bodies",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn member_app_admin_can_manage_members(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
|
||||||
|
// Bob is a member with explicit app_admin role on default.
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
grant_membership(&s.pool, bob, s.default_app, AppRole::AppAdmin).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
// Bob can list members.
|
||||||
|
let r = list_members(&s.server, &bob_token, "default").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
|
||||||
|
// Bob can add carol as viewer.
|
||||||
|
let carol = seed_user(&s.pool, "carol", "carol-pw", InstanceRole::Member).await;
|
||||||
|
let r = add_member(&s.server, &bob_token, "default", carol, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// Bob can promote carol to editor.
|
||||||
|
let r = patch_member_role(&s.server, &bob_token, "default", carol, AppRole::Editor).await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
|
||||||
|
// Bob can remove carol.
|
||||||
|
let r = remove_member(&s.server, &bob_token, "default", carol).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
// And bob can even remove himself — owner's implicit AppAdmin
|
||||||
|
// means the app isn't orphaned. This is the load-bearing test for
|
||||||
|
// the no-last-app-admin-guard decision.
|
||||||
|
let r = remove_member(&s.server, &bob_token, "default", bob).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn membership_makes_app_appear_in_members_app_list(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
let bob = seed_user(&s.pool, "bob", "bob-pw", InstanceRole::Member).await;
|
||||||
|
let bob_token = login_token(&s.server, "bob", "bob-pw").await;
|
||||||
|
|
||||||
|
// Before grant: bob sees no apps.
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {bob_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
assert!(
|
||||||
|
r.json::<Vec<Value>>().is_empty(),
|
||||||
|
"bob has no memberships → empty apps list"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant via the public POST endpoint — exercises the full
|
||||||
|
// round-trip the dashboard goes through, not just the repo seam.
|
||||||
|
let r = add_member(&s.server, &owner_token, "default", bob, AppRole::Viewer).await;
|
||||||
|
r.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
|
||||||
|
// After grant: bob sees the default app.
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.get("/api/v1/admin/apps")
|
||||||
|
.add_header("authorization", format!("Bearer {bob_token}"))
|
||||||
|
.await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let apps = r.json::<Vec<Value>>();
|
||||||
|
assert_eq!(apps.len(), 1);
|
||||||
|
assert_eq!(apps[0]["slug"], "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn add_member_with_missing_user_id_is_rejected(pool: PgPool) {
|
||||||
|
let s = boot(pool).await;
|
||||||
|
let owner_token = login_token(&s.server, "owner", "owner-pw").await;
|
||||||
|
|
||||||
|
// Body missing `user_id` — Axum's Json extractor produces a 4xx
|
||||||
|
// before our handler runs. Pinning the status to keep the contract
|
||||||
|
// honest if anyone ever swaps the extractor.
|
||||||
|
let r = s
|
||||||
|
.server
|
||||||
|
.post("/api/v1/admin/apps/default/members")
|
||||||
|
.add_header("authorization", format!("Bearer {owner_token}"))
|
||||||
|
.json(&json!({ "role": "viewer" }))
|
||||||
|
.await;
|
||||||
|
let status = r.status_code().as_u16();
|
||||||
|
assert!(
|
||||||
|
(400..500).contains(&status),
|
||||||
|
"malformed body should produce a 4xx, got {status}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { InstanceRole } from '$lib/auth';
|
import type { InstanceRole } from '$lib/auth';
|
||||||
|
import type { AppRole } from '$lib/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role: InstanceRole;
|
role?: InstanceRole;
|
||||||
|
appRole?: AppRole;
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
let { role, size = 'md' }: Props = $props();
|
let { role, appRole, size = 'md' }: Props = $props();
|
||||||
|
|
||||||
|
// Display label: app roles read better with a space ("app admin")
|
||||||
|
// than their wire form ("app_admin").
|
||||||
|
const label = $derived(
|
||||||
|
appRole ? appRole.replace('_', ' ') : (role ?? '')
|
||||||
|
);
|
||||||
|
const cls = $derived(appRole ? `chip-${appRole}` : `chip-${role}`);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</span>
|
<span class="chip {cls}" class:sm={size === 'sm'}>{label}</span>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.chip {
|
.chip {
|
||||||
@@ -42,4 +51,19 @@
|
|||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
border-color: #334155;
|
border-color: #334155;
|
||||||
}
|
}
|
||||||
|
.chip-app_admin {
|
||||||
|
background: #4c1d95;
|
||||||
|
color: #c4b5fd;
|
||||||
|
border-color: #6d28d9;
|
||||||
|
}
|
||||||
|
.chip-editor {
|
||||||
|
background: #1e3a8a;
|
||||||
|
color: #93c5fd;
|
||||||
|
border-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
.chip-viewer {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #9ca3af;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface App {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AppRole = 'app_admin' | 'editor' | 'viewer';
|
||||||
|
|
||||||
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||||
|
|
||||||
export interface AppDomain {
|
export interface AppDomain {
|
||||||
@@ -64,6 +66,11 @@ export interface AppLookupResponse {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
/// Present only when the requested slug was a retired redirect.
|
/// Present only when the requested slug was a retired redirect.
|
||||||
redirect_to?: string;
|
redirect_to?: string;
|
||||||
|
/// The caller's role on this app — owners are implicit `app_admin`,
|
||||||
|
/// admins implicit `editor`, members carry their `app_members.role`.
|
||||||
|
/// `null` only when a member somehow reaches the endpoint without
|
||||||
|
/// a membership (the server normally 403s first).
|
||||||
|
my_role: AppRole | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SlugCheckResponse {
|
export interface SlugCheckResponse {
|
||||||
@@ -289,6 +296,21 @@ export interface PatchAdminInput {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppMemberDto {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
email: string | null;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
is_active: boolean;
|
||||||
|
role: AppRole;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GrantAppMemberInput {
|
||||||
|
user_id: string;
|
||||||
|
role: AppRole;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiKeyDto {
|
export interface ApiKeyDto {
|
||||||
id: string;
|
id: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
@@ -472,6 +494,28 @@ export const api = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
appMembers: {
|
||||||
|
list: (idOrSlug: string) =>
|
||||||
|
adminRequest<AppMemberDto[]>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`
|
||||||
|
),
|
||||||
|
add: (idOrSlug: string, input: GrantAppMemberInput) =>
|
||||||
|
adminRequest<AppMemberDto>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members`,
|
||||||
|
{ method: 'POST', body: JSON.stringify(input) }
|
||||||
|
),
|
||||||
|
setRole: (idOrSlug: string, userId: string, role: AppRole) =>
|
||||||
|
adminRequest<AppMemberDto>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify({ role }) }
|
||||||
|
),
|
||||||
|
remove: (idOrSlug: string, userId: string) =>
|
||||||
|
adminRequest<null>(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
execute: async (
|
execute: async (
|
||||||
id: string,
|
id: string,
|
||||||
body: unknown,
|
body: unknown,
|
||||||
|
|||||||
@@ -5,26 +5,38 @@
|
|||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
|
type AdminDto,
|
||||||
type App,
|
type App,
|
||||||
type AppDomain,
|
type AppDomain,
|
||||||
|
type AppMemberDto,
|
||||||
|
type AppRole,
|
||||||
type Script
|
type Script
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
const SAMPLE_SOURCE =
|
const SAMPLE_SOURCE =
|
||||||
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
'#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||||
|
|
||||||
type Tab = 'scripts' | 'domains' | 'settings';
|
type Tab = 'scripts' | 'domains' | 'members' | 'settings';
|
||||||
|
|
||||||
let slug = $derived(page.params.slug ?? '');
|
let slug = $derived(page.params.slug ?? '');
|
||||||
let app = $state<App | null>(null);
|
let app = $state<App | null>(null);
|
||||||
|
let myRole = $state<AppRole | null>(null);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let activeTab = $state<Tab>('scripts');
|
let activeTab = $state<Tab>('scripts');
|
||||||
|
|
||||||
let scripts = $state<Script[]>([]);
|
let scripts = $state<Script[]>([]);
|
||||||
let domains = $state<AppDomain[]>([]);
|
let domains = $state<AppDomain[]>([]);
|
||||||
|
let members = $state<AppMemberDto[]>([]);
|
||||||
|
|
||||||
|
const canAdminMembers = $derived(myRole === 'app_admin');
|
||||||
|
|
||||||
// Script create
|
// Script create
|
||||||
let showCreateScript = $state(false);
|
let showCreateScript = $state(false);
|
||||||
@@ -55,6 +67,19 @@
|
|||||||
let removingDomain = $state(false);
|
let removingDomain = $state(false);
|
||||||
let removeDomainError = $state<string | null>(null);
|
let removeDomainError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Members tab
|
||||||
|
let eligibleUsers = $state<AdminDto[]>([]);
|
||||||
|
let eligibleLoadError = $state<string | null>(null);
|
||||||
|
let addMemberUserId = $state('');
|
||||||
|
let addMemberRole = $state<AppRole>('viewer');
|
||||||
|
let addingMember = $state(false);
|
||||||
|
let addMemberError = $state<string | null>(null);
|
||||||
|
let memberToRemove = $state<AppMemberDto | null>(null);
|
||||||
|
let removingMember = $state(false);
|
||||||
|
let removeMemberError = $state<string | null>(null);
|
||||||
|
let roleChangeBusy = $state<string | null>(null);
|
||||||
|
let memberActionError = $state<string | null>(null);
|
||||||
|
|
||||||
async function loadApp() {
|
async function loadApp() {
|
||||||
loading = true;
|
loading = true;
|
||||||
loadError = null;
|
loadError = null;
|
||||||
@@ -72,10 +97,15 @@
|
|||||||
created_at: fetched.created_at,
|
created_at: fetched.created_at,
|
||||||
updated_at: fetched.updated_at
|
updated_at: fetched.updated_at
|
||||||
};
|
};
|
||||||
|
myRole = fetched.my_role;
|
||||||
editName = app.name;
|
editName = app.name;
|
||||||
editDescription = app.description ?? '';
|
editDescription = app.description ?? '';
|
||||||
editSlug = app.slug;
|
editSlug = app.slug;
|
||||||
await Promise.all([loadScripts(app.id), loadDomains(app.id)]);
|
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
|
||||||
|
if (canAdminMembers) {
|
||||||
|
loaders.push(loadMembers(app.id), loadEligibleUsers());
|
||||||
|
}
|
||||||
|
await Promise.all(loaders);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError = e instanceof Error ? e.message : String(e);
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,6 +131,42 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadMembers(appId: string) {
|
||||||
|
try {
|
||||||
|
members = await api.appMembers.list(appId);
|
||||||
|
} catch (e) {
|
||||||
|
members = [];
|
||||||
|
memberActionError = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEligibleUsers() {
|
||||||
|
eligibleLoadError = null;
|
||||||
|
try {
|
||||||
|
const all = await api.admins.list();
|
||||||
|
// Only inactive=false members are valid invite targets — the
|
||||||
|
// API rejects everyone else anyway, so filter upfront.
|
||||||
|
eligibleUsers = all.filter(
|
||||||
|
(u) => u.is_active && u.instance_role === 'member'
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
eligibleUsers = [];
|
||||||
|
// member-with-app_admin can hit /apps/.../members but cannot
|
||||||
|
// browse /admins (gated on InstanceManageUsers). The add form
|
||||||
|
// will render disabled with the explanatory message below.
|
||||||
|
eligibleLoadError =
|
||||||
|
e instanceof ApiError && e.status === 403
|
||||||
|
? 'Only instance owners/admins can browse the user directory to invite new members.'
|
||||||
|
: e instanceof Error
|
||||||
|
? e.message
|
||||||
|
: String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibleAfterFilter = $derived(
|
||||||
|
eligibleUsers.filter((u) => !members.some((m) => m.user_id === u.id))
|
||||||
|
);
|
||||||
|
|
||||||
async function submitCreateScript(event: Event) {
|
async function submitCreateScript(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!app) return;
|
if (!app) return;
|
||||||
@@ -201,6 +267,76 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitAddMember(event: Event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!app || !addMemberUserId) return;
|
||||||
|
addingMember = true;
|
||||||
|
addMemberError = null;
|
||||||
|
try {
|
||||||
|
await api.appMembers.add(app.id, {
|
||||||
|
user_id: addMemberUserId,
|
||||||
|
role: addMemberRole
|
||||||
|
});
|
||||||
|
addMemberUserId = '';
|
||||||
|
addMemberRole = 'viewer';
|
||||||
|
await loadMembers(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
addMemberError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
addingMember = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeMemberRole(member: AppMemberDto, role: AppRole) {
|
||||||
|
if (!app || member.role === role) return;
|
||||||
|
roleChangeBusy = member.user_id;
|
||||||
|
memberActionError = null;
|
||||||
|
try {
|
||||||
|
await api.appMembers.setRole(app.id, member.user_id, role);
|
||||||
|
await loadMembers(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
memberActionError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
roleChangeBusy = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askRemoveMember(member: AppMemberDto) {
|
||||||
|
removeMemberError = null;
|
||||||
|
memberToRemove = member;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveMember() {
|
||||||
|
if (!app || !memberToRemove) return;
|
||||||
|
removingMember = true;
|
||||||
|
removeMemberError = null;
|
||||||
|
try {
|
||||||
|
const removedSelf = !!me && memberToRemove.user_id === me.id;
|
||||||
|
await api.appMembers.remove(app.id, memberToRemove.user_id);
|
||||||
|
memberToRemove = null;
|
||||||
|
if (removedSelf) {
|
||||||
|
// We just revoked our own access to this app; the next
|
||||||
|
// fetch of /apps/{slug} would 403. Bounce back to the
|
||||||
|
// apps list rather than render a broken tab.
|
||||||
|
await goto(`${base}/apps`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadMembers(app.id);
|
||||||
|
} catch (e) {
|
||||||
|
removeMemberError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
removingMember = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString();
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function askDeleteApp() {
|
function askDeleteApp() {
|
||||||
deleteAppError = null;
|
deleteAppError = null;
|
||||||
confirmingDeleteApp = true;
|
confirmingDeleteApp = true;
|
||||||
@@ -258,6 +394,13 @@
|
|||||||
class:active={activeTab === 'domains'}
|
class:active={activeTab === 'domains'}
|
||||||
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
>
|
>
|
||||||
|
{#if canAdminMembers}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'members'}
|
||||||
|
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={activeTab === 'settings'}
|
class:active={activeTab === 'settings'}
|
||||||
@@ -365,6 +508,121 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
{:else if activeTab === 'members' && canAdminMembers}
|
||||||
|
<section>
|
||||||
|
<h2>Members</h2>
|
||||||
|
<p class="muted">
|
||||||
|
Users with explicit access to this app. Instance owners and admins
|
||||||
|
already have implicit access — they are not listed here. Use the Users
|
||||||
|
page to invite a <code>member</code> first, then grant them app access
|
||||||
|
below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="create-form" onsubmit={submitAddMember}>
|
||||||
|
<div class="row">
|
||||||
|
<label class="grow">
|
||||||
|
<span>User</span>
|
||||||
|
<select
|
||||||
|
bind:value={addMemberUserId}
|
||||||
|
disabled={!!eligibleLoadError || eligibleAfterFilter.length === 0}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>Pick a member to invite…</option>
|
||||||
|
{#each eligibleAfterFilter as u (u.id)}
|
||||||
|
<option value={u.id}>{u.username}{u.email ? ` (${u.email})` : ''}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Role</span>
|
||||||
|
<select bind:value={addMemberRole} disabled={!!eligibleLoadError}>
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="editor">editor</option>
|
||||||
|
<option value="app_admin">app admin</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{#if eligibleLoadError}
|
||||||
|
<p class="muted">{eligibleLoadError}</p>
|
||||||
|
{:else if eligibleAfterFilter.length === 0}
|
||||||
|
<p class="muted">
|
||||||
|
No eligible users to invite. Create a <code>member</code> on the Users
|
||||||
|
page first.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if addMemberError}
|
||||||
|
<div class="error">{addMemberError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={addingMember || !addMemberUserId || !!eligibleLoadError}
|
||||||
|
>
|
||||||
|
{addingMember ? 'Adding…' : 'Add member'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if memberActionError}
|
||||||
|
<div class="error">{memberActionError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if members.length === 0}
|
||||||
|
<p class="muted">No explicit members yet.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>User</div>
|
||||||
|
<div>Instance</div>
|
||||||
|
<div>App role</div>
|
||||||
|
<div>Joined</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each members as m (m.user_id)}
|
||||||
|
<div class="row member-row" class:inactive={!m.is_active}>
|
||||||
|
<div>
|
||||||
|
<strong>{m.username}</strong>
|
||||||
|
{#if m.email}<span class="muted">{m.email}</span>{/if}
|
||||||
|
{#if !m.is_active}<span class="muted">(inactive)</span>{/if}
|
||||||
|
</div>
|
||||||
|
<div><RoleChip role={m.instance_role} size="sm" /></div>
|
||||||
|
<div><RoleChip appRole={m.role} size="sm" /></div>
|
||||||
|
<div>{shortDate(m.created_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<ActionMenu
|
||||||
|
label="Member actions for {m.username}"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Make app admin',
|
||||||
|
disabled:
|
||||||
|
m.role === 'app_admin' || roleChangeBusy === m.user_id,
|
||||||
|
onClick: () => changeMemberRole(m, 'app_admin')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Make editor',
|
||||||
|
disabled:
|
||||||
|
m.role === 'editor' || roleChangeBusy === m.user_id,
|
||||||
|
onClick: () => changeMemberRole(m, 'editor')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Make viewer',
|
||||||
|
disabled:
|
||||||
|
m.role === 'viewer' || roleChangeBusy === m.user_id,
|
||||||
|
onClick: () => changeMemberRole(m, 'viewer')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Remove from app',
|
||||||
|
danger: true,
|
||||||
|
onClick: () => askRemoveMember(m)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
{:else if activeTab === 'settings'}
|
{:else if activeTab === 'settings'}
|
||||||
<section>
|
<section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
@@ -502,6 +760,26 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</ConfirmModal>
|
</ConfirmModal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if memberToRemove}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Remove {memberToRemove.username} from {app.name}"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Remove member"
|
||||||
|
busyLabel="Removing…"
|
||||||
|
busy={removingMember}
|
||||||
|
onConfirm={confirmRemoveMember}
|
||||||
|
onCancel={() => (memberToRemove = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<strong>{memberToRemove.username}</strong> will lose access to this
|
||||||
|
app. Their other app memberships and account are untouched.
|
||||||
|
</p>
|
||||||
|
{#if removeMemberError}
|
||||||
|
<p class="modal-error">{removeMemberError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -744,4 +1022,60 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
background: #1e0a0a;
|
background: #1e0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.create-form select {
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-form .row > label.grow {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr 1fr 3rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .head-row {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .member-row.inactive {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .member-row strong {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .member-row .muted {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table .actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
# Project Blueprint: Lightweight Event-Based Serverless Cloud
|
||||||
|
|
||||||
**Status**: Phase 4 — Blueprint Complete
|
**Status**: Phase 4 — Blueprint Complete
|
||||||
**Last Updated**: 2026-04-10
|
**Last Updated**: 2026-05-27
|
||||||
**Audience**: Solo developer (DIY self-hosted)
|
**Audience**: Solo developer (DIY self-hosted)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1156,6 +1156,35 @@ DELETE /api/v1/admin/api-keys/{id} — caller's own only
|
|||||||
|
|
||||||
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
Every existing `/api/v1/admin/*` endpoint is re-gated from "any authed admin" to a specific `Capability`. Request/response shapes are unchanged; what changes is the set of callers each endpoint accepts (a `member` now gets 403 on app surfaces they're not part of, where before they would have been 401-or-200 depending only on session validity).
|
||||||
|
|
||||||
|
### App Member Management Endpoints
|
||||||
|
|
||||||
|
Exposes the `app_members` table as a first-class CRUD surface so app admins can manage who they share an app with from the dashboard, not just from SQL.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/apps/{id_or_slug}/members — list members (ordered by username),
|
||||||
|
joined with admin_users for
|
||||||
|
username / email / instance_role / is_active
|
||||||
|
POST /api/v1/admin/apps/{id_or_slug}/members — { user_id, role } → 201 enriched DTO
|
||||||
|
409 on duplicate (promotions go through PATCH)
|
||||||
|
422 if target user is_active = false
|
||||||
|
422 if target user instance_role != 'member'
|
||||||
|
(owners/admins have implicit authority;
|
||||||
|
an explicit row would be dead weight)
|
||||||
|
PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id} — { role } → 200 enriched DTO
|
||||||
|
404 if no existing membership
|
||||||
|
DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id} — 204 (idempotent — 204 also when missing)
|
||||||
|
```
|
||||||
|
|
||||||
|
All four are gated on `Capability::AppAdmin(app_id)`. Editors and viewers get 403 on list and never see the dashboard's Members tab.
|
||||||
|
|
||||||
|
**`my_role` on the app lookup endpoint.** `GET /api/v1/admin/apps/{id_or_slug}` now returns an additional `my_role: Option<AppRole>`, computed server-side from the principal: `Owner → app_admin`, `Admin → editor`, `Member → app_members.role`. The dashboard uses this single field to decide whether to render the Members tab (visible iff `my_role == app_admin`), keeping API and UI gate logic identical.
|
||||||
|
|
||||||
|
**No last-app-admin guard.** Unlike the last-owner protection on `admin_users`, removing the final `app_admin` row from `app_members` is allowed. Every `owner` instance-role user implicitly satisfies `Capability::AppAdmin(_)` via the top-level `role_grants` branch, so no app can become permanently orphaned — an owner can always re-issue grants. The `admin` instance role is only implicit *editor*, so it does **not** provide a fallback path; the owner guarantee alone is what makes the no-guard position safe.
|
||||||
|
|
||||||
|
**Dead-row sweep on promotion (deferred).** Promoting a user from `member` → `admin`/`owner` leaves their `app_members` rows in place. They become inert (implicit grants supersede), but are not auto-deleted. A future hook can sweep them; harmless for now.
|
||||||
|
|
||||||
|
Additive within `/api/v1/admin/...` — no API major bump per [docs/versioning.md](docs/versioning.md).
|
||||||
|
|
||||||
### Out of Scope (Phase 3.5)
|
### Out of Scope (Phase 3.5)
|
||||||
|
|
||||||
Schema room only, not built:
|
Schema room only, not built:
|
||||||
@@ -1164,7 +1193,7 @@ Schema room only, not built:
|
|||||||
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
- **MFA / TOTP** — `mfa_secret` column reserved on `admin_users`.
|
||||||
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
- **Service accounts** — reserved as a future table; for now, every API key belongs to a human `admin_users` row.
|
||||||
|
|
||||||
Defer to follow-up sessions: dashboard surfaces for invites / member management / key minting (curl is the supported interface this phase), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl is the supported interface this phase — member management has a dashboard tab; see above), OIDC / SAML / SCIM, the `picloud` CLI binary itself, email/SMTP delivery of invites, audit log shipping.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user