Compare commits
48 Commits
feat/users
...
chore/ui-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 | ||
|
|
b459b99fe9 | ||
|
|
f694a6d504 | ||
|
|
70b66451d6 | ||
|
|
c4fa53052d | ||
|
|
2f6840fe3e | ||
|
|
75c815d02a | ||
|
|
d9c3d4d661 | ||
|
|
bef4d34c43 | ||
|
|
99a3ed1b6b | ||
|
|
4644ea4919 | ||
|
|
ec3c768262 | ||
|
|
3e72ddde78 | ||
|
|
cd20ffb580 | ||
|
|
cddd479fd2 | ||
|
|
8bbcdd86aa | ||
|
|
2d56e42699 | ||
|
|
f9d9ed8cb4 | ||
|
|
c17f8a5bd9 | ||
|
|
7198fb4d0e | ||
|
|
029a4a199f | ||
|
|
74f7b3b631 | ||
|
|
e6fc6e6a0e | ||
|
|
66b84abf6d | ||
|
|
a9fc838577 | ||
|
|
2948875a96 | ||
|
|
b7175cc581 | ||
|
|
d40ebf65a2 | ||
|
|
816a13b920 | ||
|
|
248571dcde | ||
|
|
85bbabcbdf | ||
|
|
1314420fca | ||
|
|
33697a2766 | ||
|
|
6eb32a78bf | ||
|
|
fc35d59236 | ||
|
|
0c9f11558a | ||
|
|
39a6df2bfe | ||
|
|
d21cbdb164 | ||
|
|
700ae7b7d1 | ||
|
|
f16ff22a5a | ||
|
|
bd2258499e | ||
|
|
df691038d7 | ||
|
|
3688c26cb4 |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -30,6 +30,17 @@ config.local.toml
|
|||||||
/dashboard/build
|
/dashboard/build
|
||||||
/dashboard/.env
|
/dashboard/.env
|
||||||
|
|
||||||
|
# Dashboard — Playwright E2E
|
||||||
|
/dashboard/tests/e2e/.auth
|
||||||
|
/dashboard/tests/e2e/.results
|
||||||
|
/dashboard/playwright-report
|
||||||
|
/dashboard/test-results
|
||||||
|
/dashboard/.playwright
|
||||||
|
# When playwright is invoked from the repo root by accident, these
|
||||||
|
# also land here.
|
||||||
|
/playwright-report
|
||||||
|
/test-results
|
||||||
|
|
||||||
# Caddy
|
# Caddy
|
||||||
/caddy/data
|
/caddy/data
|
||||||
/caddy/config
|
/caddy/config
|
||||||
|
|||||||
@@ -69,12 +69,14 @@ pub trait AdminUserRepository: Send + Sync {
|
|||||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||||
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||||
/// env-var bootstrap path; admin-creates-admin flows pass an
|
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||||
/// explicit role.
|
/// explicit role. `email` is optional — pass `None` to leave the
|
||||||
|
/// column NULL.
|
||||||
async fn create(
|
async fn create(
|
||||||
&self,
|
&self,
|
||||||
username: &str,
|
username: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
instance_role: InstanceRole,
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
async fn update_username(
|
async fn update_username(
|
||||||
&self,
|
&self,
|
||||||
@@ -86,6 +88,12 @@ pub trait AdminUserRepository: Send + Sync {
|
|||||||
id: AdminUserId,
|
id: AdminUserId,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
|
/// Set or clear the email address. `None` writes NULL to the column.
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||||
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||||
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||||
/// before invoking when role transitions away from `Owner`.
|
/// before invoking when role transitions away from `Owner`.
|
||||||
@@ -192,24 +200,37 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
username: &str,
|
username: &str,
|
||||||
password_hash: &str,
|
password_hash: &str,
|
||||||
instance_role: InstanceRole,
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
"INSERT INTO admin_users (username, password_hash, instance_role) \
|
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
|
||||||
VALUES ($1, $2, $3) \
|
VALUES ($1, $2, $3, $4) \
|
||||||
RETURNING id, username, is_active, instance_role, email, \
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
created_at, updated_at, last_login_at",
|
created_at, updated_at, last_login_at",
|
||||||
)
|
)
|
||||||
.bind(username)
|
.bind(username)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
.bind(instance_role.as_str())
|
.bind(instance_role.as_str())
|
||||||
|
.bind(email)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(row) => row.try_into(),
|
Ok(row) => row.try_into(),
|
||||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
// username and email both have unique constraints; the
|
||||||
),
|
// create path can collide on either, so peek at the
|
||||||
|
// constraint name to surface the right error.
|
||||||
|
if e.constraint() == Some("admin_users_email_key") {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateEmail(
|
||||||
|
email.unwrap_or("").to_string(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Err(AdminUserRepositoryError::DuplicateUsername(
|
||||||
|
username.to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(e) => Err(e.into()),
|
Err(e) => Err(e.into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,6 +280,32 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
|||||||
.and_then(TryInto::try_into)
|
.and_then(TryInto::try_into)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
id: AdminUserId,
|
||||||
|
email: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||||
|
"UPDATE admin_users SET email = $2, updated_at = NOW() \
|
||||||
|
WHERE id = $1 \
|
||||||
|
RETURNING id, username, is_active, instance_role, email, \
|
||||||
|
created_at, updated_at, last_login_at",
|
||||||
|
)
|
||||||
|
.bind(id.into_inner())
|
||||||
|
.bind(email)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match res {
|
||||||
|
Ok(Some(row)) => row.try_into(),
|
||||||
|
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||||
|
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||||
|
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
|
||||||
|
),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn update_instance_role(
|
async fn update_instance_role(
|
||||||
&self,
|
&self,
|
||||||
id: AdminUserId,
|
id: AdminUserId,
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ pub struct CreateAdminRequest {
|
|||||||
/// channel that defaults to `Owner`.
|
/// channel that defaults to `Owner`.
|
||||||
#[serde(default = "default_create_role")]
|
#[serde(default = "default_create_role")]
|
||||||
pub instance_role: InstanceRole,
|
pub instance_role: InstanceRole,
|
||||||
|
/// Optional contact email. Blank/whitespace is normalized to None.
|
||||||
|
#[serde(default)]
|
||||||
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn default_create_role() -> InstanceRole {
|
const fn default_create_role() -> InstanceRole {
|
||||||
@@ -107,6 +110,26 @@ pub struct PatchAdminRequest {
|
|||||||
pub password: Option<String>,
|
pub password: Option<String>,
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
pub instance_role: Option<InstanceRole>,
|
pub instance_role: Option<InstanceRole>,
|
||||||
|
/// JSON Merge Patch (RFC 7396) semantics for email:
|
||||||
|
/// absent → don't change
|
||||||
|
/// null → clear (set DB column to NULL)
|
||||||
|
/// "<string>" → set to that string
|
||||||
|
/// `Option<Option<T>>` is the idiomatic Rust shape for that
|
||||||
|
/// tri-state; the custom deserializer below distinguishes the
|
||||||
|
/// "missing" case from the "present-and-null" case that serde
|
||||||
|
/// would otherwise collapse together.
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
#[serde(default, deserialize_with = "deserialize_present_optional")]
|
||||||
|
pub email: Option<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::option_option)]
|
||||||
|
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||||
|
where
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
Ok(Some(Option::<T>::deserialize(deserializer)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -169,10 +192,11 @@ async fn create_admin(
|
|||||||
let username = input.username.trim();
|
let username = input.username.trim();
|
||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
validate_password(&input.password)?;
|
||||||
|
let email = normalize_email(input.email.as_deref())?;
|
||||||
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||||
let row = state
|
let row = state
|
||||||
.users
|
.users
|
||||||
.create(username, &hash, input.instance_role)
|
.create(username, &hash, input.instance_role, email.as_deref())
|
||||||
.await?;
|
.await?;
|
||||||
Ok((StatusCode::CREATED, Json(row.into())))
|
Ok((StatusCode::CREATED, Json(row.into())))
|
||||||
}
|
}
|
||||||
@@ -216,6 +240,12 @@ async fn patch_admin(
|
|||||||
// for the initial cut.)
|
// for the initial cut.)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(email_patch) = input.email.as_ref() {
|
||||||
|
// email_patch is Some(None) → clear, Some(Some(s)) → set.
|
||||||
|
let normalized = normalize_email(email_patch.as_deref())?;
|
||||||
|
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(new_role) = input.instance_role {
|
if let Some(new_role) = input.instance_role {
|
||||||
// Self-elevation guard: only an owner can promote anyone TO
|
// Self-elevation guard: only an owner can promote anyone TO
|
||||||
// owner. An admin cannot turn themselves (or anyone else)
|
// owner. An admin cannot turn themselves (or anyone else)
|
||||||
@@ -358,6 +388,26 @@ fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Trim and reject empty / pathological emails, returning the
|
||||||
|
/// canonical form (or None when the input was blank). The shape
|
||||||
|
/// check is intentionally loose — we mainly want to reject blanks
|
||||||
|
/// and obvious junk; real verification is a future concern.
|
||||||
|
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
|
||||||
|
let Some(raw) = raw else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if trimmed.len() > 254 || !trimmed.contains('@') {
|
||||||
|
return Err(AdminApiError::InvalidEmail(
|
||||||
|
"email must contain '@' and be at most 254 characters".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(trimmed.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Errors
|
// Errors
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -373,6 +423,9 @@ pub enum AdminApiError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
InvalidPassword(String),
|
InvalidPassword(String),
|
||||||
|
|
||||||
|
#[error("{0}")]
|
||||||
|
InvalidEmail(String),
|
||||||
|
|
||||||
#[error("cannot leave the system with zero active admins")]
|
#[error("cannot leave the system with zero active admins")]
|
||||||
LastActiveAdmin,
|
LastActiveAdmin,
|
||||||
|
|
||||||
@@ -414,6 +467,7 @@ impl IntoResponse for AdminApiError {
|
|||||||
) => (StatusCode::CONFLICT, self.to_string()),
|
) => (StatusCode::CONFLICT, self.to_string()),
|
||||||
Self::InvalidUsername(_)
|
Self::InvalidUsername(_)
|
||||||
| Self::InvalidPassword(_)
|
| Self::InvalidPassword(_)
|
||||||
|
| Self::InvalidEmail(_)
|
||||||
| Self::LastActiveAdmin
|
| Self::LastActiveAdmin
|
||||||
| Self::LastActiveOwner
|
| Self::LastActiveOwner
|
||||||
| Self::CannotEscalate
|
| Self::CannotEscalate
|
||||||
|
|||||||
@@ -270,10 +270,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
|||||||
Path(id): Path<ScriptId>,
|
Path(id): Path<ScriptId>,
|
||||||
) -> Result<StatusCode, ApiError> {
|
) -> Result<StatusCode, ApiError> {
|
||||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||||
|
// Delete is gated tighter than Save: editors can edit scripts but
|
||||||
|
// only app_admin / instance admin / owner can remove them. See
|
||||||
|
// blueprint §11.6.
|
||||||
require(
|
require(
|
||||||
state.authz.as_ref(),
|
state.authz.as_ref(),
|
||||||
&principal,
|
&principal,
|
||||||
Capability::AppWriteScript(script.app_id),
|
Capability::AppAdmin(script.app_id),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
state.repo.delete(id).await?;
|
state.repo.delete(id).await?;
|
||||||
|
|||||||
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` and `Admin` both map to `app_admin` (implicit per
|
||||||
|
/// blueprint §11.6); `Member` carries its explicit
|
||||||
|
/// `app_members.role`.
|
||||||
|
pub my_role: Option<AppRole>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -209,12 +215,30 @@ 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`
|
||||||
|
/// and `Admin` are both implicit `AppAdmin` 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 | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||||
|
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 +453,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 +561,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 {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use axum::response::{IntoResponse, Json, Response};
|
|||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||||
use picloud_shared::AdminUserId;
|
use picloud_shared::{AdminUserId, InstanceRole};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
@@ -63,6 +63,8 @@ pub struct LoginResponse {
|
|||||||
pub struct AdminUserDto {
|
pub struct AdminUserDto {
|
||||||
pub id: AdminUserId,
|
pub id: AdminUserId,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
pub instance_role: InstanceRole,
|
||||||
|
pub email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -87,9 +89,11 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (stored_hash, user_id, username, is_active) = match creds {
|
// username from creds is discarded — the re-fetch below carries the
|
||||||
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
// canonical row used in the response DTO.
|
||||||
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
let (stored_hash, user_id, is_active) = match creds {
|
||||||
|
Some(c) => (c.password_hash, Some(c.id), c.is_active),
|
||||||
|
None => (DUMMY_HASH.to_string(), None, false),
|
||||||
};
|
};
|
||||||
|
|
||||||
let password_ok = verify_password(&stored_hash, &input.password);
|
let password_ok = verify_password(&stored_hash, &input.password);
|
||||||
@@ -98,6 +102,18 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
}
|
}
|
||||||
let user_id = user_id.unwrap();
|
let user_id = user_id.unwrap();
|
||||||
|
|
||||||
|
// Re-fetch the full row so the login response carries the same
|
||||||
|
// shape /me does (instance_role, email). The credentials struct
|
||||||
|
// intentionally omits email; one extra query per login is fine.
|
||||||
|
let user_row = match state.users.get(user_id).await {
|
||||||
|
Ok(Some(row)) => row,
|
||||||
|
Ok(None) => return invalid_credentials(),
|
||||||
|
Err(err) => {
|
||||||
|
tracing::error!(?err, "admin_users lookup after login failed");
|
||||||
|
return internal_error();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let token = generate_session_token();
|
let token = generate_session_token();
|
||||||
let expires_at = Utc::now()
|
let expires_at = Utc::now()
|
||||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||||
@@ -130,8 +146,10 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
|||||||
headers,
|
headers,
|
||||||
Json(LoginResponse {
|
Json(LoginResponse {
|
||||||
user: AdminUserDto {
|
user: AdminUserDto {
|
||||||
id: user_id,
|
id: user_row.id,
|
||||||
username,
|
username: user_row.username,
|
||||||
|
instance_role: user_row.instance_role,
|
||||||
|
email: user_row.email,
|
||||||
},
|
},
|
||||||
token: token.raw,
|
token: token.raw,
|
||||||
expires_at,
|
expires_at,
|
||||||
@@ -171,6 +189,8 @@ async fn me(
|
|||||||
Ok(Some(row)) => Json(AdminUserDto {
|
Ok(Some(row)) => Json(AdminUserDto {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
|
instance_role: row.instance_role,
|
||||||
|
email: row.email,
|
||||||
})
|
})
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Ok(None) => invalid_credentials(),
|
Ok(None) => invalid_credentials(),
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
|||||||
&username,
|
&username,
|
||||||
&password_hash,
|
&password_hash,
|
||||||
picloud_shared::InstanceRole::Owner,
|
picloud_shared::InstanceRole::Owner,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
info!(username = %username, "bootstrapped initial admin user");
|
info!(username = %username, "bootstrapped initial admin user");
|
||||||
@@ -176,13 +177,14 @@ mod tests {
|
|||||||
username: &str,
|
username: &str,
|
||||||
_password_hash: &str,
|
_password_hash: &str,
|
||||||
instance_role: InstanceRole,
|
instance_role: InstanceRole,
|
||||||
|
email: Option<&str>,
|
||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
let row = AdminUserRow {
|
let row = AdminUserRow {
|
||||||
id: AdminUserId::new(),
|
id: AdminUserId::new(),
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
is_active: true,
|
is_active: true,
|
||||||
instance_role,
|
instance_role,
|
||||||
email: None,
|
email: email.map(str::to_string),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
last_login_at: None,
|
last_login_at: None,
|
||||||
@@ -204,6 +206,13 @@ mod tests {
|
|||||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
async fn update_email(
|
||||||
|
&self,
|
||||||
|
_i: AdminUserId,
|
||||||
|
_e: Option<&str>,
|
||||||
|
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
async fn update_instance_role(
|
async fn update_instance_role(
|
||||||
&self,
|
&self,
|
||||||
_i: AdminUserId,
|
_i: AdminUserId,
|
||||||
@@ -272,7 +281,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn populated_db_is_noop() {
|
async fn populated_db_is_noop() {
|
||||||
let repo = InMemoryRepo::default();
|
let repo = InMemoryRepo::default();
|
||||||
repo.create("seeded", "x", InstanceRole::Owner)
|
repo.create("seeded", "x", InstanceRole::Owner, None)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let env = BootstrapEnv {
|
let env = BootstrapEnv {
|
||||||
|
|||||||
@@ -199,21 +199,14 @@ async fn role_grants(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
|
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
|
||||||
/// can create apps and manage users, but NOT touch instance-wide
|
/// They can create apps, manage users, and take any app-scoped action
|
||||||
/// settings or take app-admin-only actions on apps they're not
|
/// on any app without an explicit `app_members` row — single-human
|
||||||
/// explicitly app_admin of. Everything not in this set falls through
|
/// installs would otherwise need to add themselves to every new app.
|
||||||
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
|
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
|
||||||
|
/// owner-only.
|
||||||
const fn admin_grants(cap: Capability) -> bool {
|
const fn admin_grants(cap: Capability) -> bool {
|
||||||
matches!(
|
!matches!(cap, Capability::InstanceManageSettings)
|
||||||
cap,
|
|
||||||
Capability::InstanceCreateApp
|
|
||||||
| Capability::InstanceManageUsers
|
|
||||||
| Capability::AppRead(_)
|
|
||||||
| Capability::AppWriteScript(_)
|
|
||||||
| Capability::AppWriteRoute(_)
|
|
||||||
| Capability::AppLogRead(_)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Member has zero instance authority. App authority requires an
|
/// Member has zero instance authority. App authority requires an
|
||||||
@@ -357,10 +350,23 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
|
async fn admin_cannot_manage_instance_settings() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Admin);
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::InstanceManageSettings)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_is_implicit_app_admin_on_every_app() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
let p = principal(InstanceRole::Admin);
|
let p = principal(InstanceRole::Admin);
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
|
// Instance-scoped allowances.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
@@ -371,36 +377,22 @@ mod tests {
|
|||||||
.unwrap(),
|
.unwrap(),
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
);
|
);
|
||||||
|
// Editor-like + app-admin grants both succeed without any
|
||||||
|
// app_members row.
|
||||||
|
for cap in [
|
||||||
|
Capability::AppRead(app),
|
||||||
|
Capability::AppWriteScript(app),
|
||||||
|
Capability::AppWriteRoute(app),
|
||||||
|
Capability::AppLogRead(app),
|
||||||
|
Capability::AppManageDomains(app),
|
||||||
|
Capability::AppAdmin(app),
|
||||||
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
can(&repo, &p, Capability::InstanceManageSettings)
|
can(&repo, &p, cap).await.unwrap(),
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
// Editor-like grants succeed
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppWriteScript(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Allow,
|
Decision::Allow,
|
||||||
|
"admin denied app-scoped capability {cap:?}"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
}
|
||||||
can(&repo, &p, Capability::AppWriteRoute(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Allow,
|
|
||||||
);
|
|
||||||
// App-admin grants do not
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppManageDomains(app))
|
|
||||||
.await
|
|
||||||
.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
|
||||||
Decision::Deny,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -474,6 +466,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
|
||||||
|
/// (Delete). The script-delete handler gates on the latter so the
|
||||||
|
/// API can't be tricked into letting an editor remove the script
|
||||||
|
/// they were only allowed to edit.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn editor_can_write_scripts_but_not_delete_them() {
|
||||||
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
let p = principal(InstanceRole::Member);
|
||||||
|
let app = AppId::new();
|
||||||
|
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||||
|
|
||||||
|
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_allow());
|
||||||
|
// Delete is gated on AppAdmin in the handler — editors must be
|
||||||
|
// denied here for that gate to bite.
|
||||||
|
assert_eq!(
|
||||||
|
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||||
|
Decision::Deny,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||||
let repo = InMemoryAuthzRepo::default();
|
let repo = InMemoryAuthzRepo::default();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async fn server_with_app(pool: PgPool) -> (TestServer, String) {
|
|||||||
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
let auth = picloud::AuthDeps::from_pool(pool.clone());
|
||||||
let hash = hash_password("test-pw").expect("hash");
|
let hash = hash_password("test-pw").expect("hash");
|
||||||
auth.users
|
auth.users
|
||||||
.create("test-admin", &hash, InstanceRole::Owner)
|
.create("test-admin", &hash, InstanceRole::Owner, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed admin");
|
.expect("seed admin");
|
||||||
|
|
||||||
@@ -93,6 +93,68 @@ async fn healthz_responds_ok(pool: PgPool) {
|
|||||||
assert_eq!(r.text(), "ok");
|
assert_eq!(r.text(), "ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Auth
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn auth_me_returns_principal_with_role_and_email(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
let r = s.get("/api/v1/admin/auth/me").await;
|
||||||
|
r.assert_status_ok();
|
||||||
|
let body: Value = r.json();
|
||||||
|
assert_eq!(body["username"], "test-admin");
|
||||||
|
assert_eq!(body["instance_role"], "owner");
|
||||||
|
// Seeded admin has no email — must round-trip as null, not be missing.
|
||||||
|
assert!(
|
||||||
|
body.get("email").is_some_and(Value::is_null),
|
||||||
|
"email should be present and null, got: {body}"
|
||||||
|
);
|
||||||
|
assert!(body["id"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
|
async fn create_admin_accepts_email_and_patch_clears_it(pool: PgPool) {
|
||||||
|
let s = server(pool).await;
|
||||||
|
// Create with email set.
|
||||||
|
let created = s
|
||||||
|
.post("/api/v1/admin/admins")
|
||||||
|
.json(&json!({
|
||||||
|
"username": "alice",
|
||||||
|
"password": "correct-horse-battery",
|
||||||
|
"instance_role": "member",
|
||||||
|
"email": "alice@example.com",
|
||||||
|
}))
|
||||||
|
.await;
|
||||||
|
created.assert_status(axum::http::StatusCode::CREATED);
|
||||||
|
let body: Value = created.json();
|
||||||
|
let alice_id = body["id"].as_str().expect("id").to_string();
|
||||||
|
assert_eq!(body["email"], "alice@example.com");
|
||||||
|
|
||||||
|
// Patch with email present-and-null clears it.
|
||||||
|
let cleared = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": null }))
|
||||||
|
.await;
|
||||||
|
cleared.assert_status_ok();
|
||||||
|
assert!(cleared.json::<Value>()["email"].is_null());
|
||||||
|
|
||||||
|
// Patch with email omitted is a no-op (doesn't clobber a re-set).
|
||||||
|
let reset = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "email": "alice2@example.com" }))
|
||||||
|
.await;
|
||||||
|
reset.assert_status_ok();
|
||||||
|
let omit = s
|
||||||
|
.patch(&format!("/api/v1/admin/admins/{alice_id}"))
|
||||||
|
.json(&json!({ "username": "alice" })) // no email key
|
||||||
|
.await;
|
||||||
|
omit.assert_status_ok();
|
||||||
|
assert_eq!(omit.json::<Value>()["email"], "alice2@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Script CRUD
|
// Script CRUD
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async fn boot(pool: PgPool) -> Seeded {
|
|||||||
let hash = hash_password("owner-pw").expect("hash");
|
let hash = hash_password("owner-pw").expect("hash");
|
||||||
let owner = auth
|
let owner = auth
|
||||||
.users
|
.users
|
||||||
.create("owner", &hash, InstanceRole::Owner)
|
.create("owner", &hash, InstanceRole::Owner, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed owner");
|
.expect("seed owner");
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ async fn seed_user(
|
|||||||
) -> AdminUserId {
|
) -> AdminUserId {
|
||||||
let repo = PostgresAdminUserRepository::new(pool.clone());
|
let repo = PostgresAdminUserRepository::new(pool.clone());
|
||||||
let hash = hash_password(password).expect("hash");
|
let hash = hash_password(password).expect("hash");
|
||||||
repo.create(username, &hash, role)
|
repo.create(username, &hash, role, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed user")
|
.expect("seed user")
|
||||||
.id
|
.id
|
||||||
@@ -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
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -227,7 +293,7 @@ async fn owner_access_matrix(pool: PgPool) {
|
|||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[sqlx::test(migrations = "../manager-core/migrations")]
|
#[sqlx::test(migrations = "../manager-core/migrations")]
|
||||||
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
|
||||||
let s = boot(pool.clone()).await;
|
let s = boot(pool.clone()).await;
|
||||||
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
|
||||||
let token = login_token(&s.server, "alice", "alice-pw").await;
|
let token = login_token(&s.server, "alice", "alice-pw").await;
|
||||||
@@ -239,24 +305,34 @@ async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
|
|||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: read default app (admin is implicit editor everywhere).
|
// Allowed: read default app — admin is implicit app_admin
|
||||||
|
// everywhere (per blueprint §11.6).
|
||||||
s.server
|
s.server
|
||||||
.get("/api/v1/admin/apps/default")
|
.get("/api/v1/admin/apps/default")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await
|
.await
|
||||||
.assert_status_ok();
|
.assert_status_ok();
|
||||||
|
|
||||||
// Allowed: write scripts (implicit editor).
|
// Allowed: write scripts.
|
||||||
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
|
||||||
assert!(script["id"].is_string());
|
assert!(script["id"].is_string());
|
||||||
|
|
||||||
// Denied: delete the default app (AppAdmin only).
|
// Allowed: list app members (AppAdmin gate). Pre-3.5.x this
|
||||||
let denied = s
|
// 403'd; now it's the same allow as the owner sees.
|
||||||
.server
|
s.server
|
||||||
.delete("/api/v1/admin/apps/default")
|
.get("/api/v1/admin/apps/default/members")
|
||||||
.add_header("authorization", format!("Bearer {token}"))
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
.await;
|
.await
|
||||||
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
|
.assert_status_ok();
|
||||||
|
|
||||||
|
// Allowed: delete the default app (AppAdmin). ?force=true because
|
||||||
|
// the script we created above pushes us past the soft no-cascade
|
||||||
|
// guard — this test is about the capability, not the cascade.
|
||||||
|
s.server
|
||||||
|
.delete("/api/v1/admin/apps/default?force=true")
|
||||||
|
.add_header("authorization", format!("Bearer {token}"))
|
||||||
|
.await
|
||||||
|
.assert_status(axum::http::StatusCode::NO_CONTENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
@@ -645,3 +721,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 app_admin everywhere (post-§11.6 update).
|
||||||
|
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("app_admin"),
|
||||||
|
"admin reports app_admin"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,3 +2,9 @@
|
|||||||
build
|
build
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
# Playwright generated artifacts
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
|
tests/e2e/.auth
|
||||||
|
tests/e2e/.results
|
||||||
|
|||||||
64
dashboard/package-lock.json
generated
64
dashboard/package-lock.json
generated
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
@@ -885,6 +886,22 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -3010,6 +3027,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"lint": "prettier --check . && eslint .",
|
"lint": "prettier --check . && eslint .",
|
||||||
"test": "vitest run"
|
"test": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:install": "playwright install --with-deps chromium"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
"@sveltejs/kit": "^2.17.0",
|
"@sveltejs/kit": "^2.17.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
|||||||
51
dashboard/playwright.config.ts
Normal file
51
dashboard/playwright.config.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||||
|
// baseURL is the origin only — the SvelteKit dashboard is mounted at
|
||||||
|
// `/admin` (svelte.config.js paths.base), so tests use full paths like
|
||||||
|
// `/admin/login` rather than relying on baseURL path resolution.
|
||||||
|
const DASHBOARD_BASE = process.env.E2E_BASE_URL ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests/e2e',
|
||||||
|
outputDir: './tests/e2e/.results',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
// Local: 1 retry to absorb dev-server warmup flakiness. CI: 2.
|
||||||
|
retries: process.env.CI ? 2 : 1,
|
||||||
|
// Cap at 4 workers locally to keep the shared Vite dev server
|
||||||
|
// from getting stampeded during cold-start compiles.
|
||||||
|
workers: process.env.CI ? 2 : 4,
|
||||||
|
reporter: process.env.CI ? [['html'], ['github']] : 'html',
|
||||||
|
globalSetup: './tests/e2e/global-setup.ts',
|
||||||
|
expect: { timeout: 5_000 },
|
||||||
|
use: {
|
||||||
|
baseURL: DASHBOARD_BASE,
|
||||||
|
actionTimeout: 10_000,
|
||||||
|
navigationTimeout: 30_000,
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'only-on-failure',
|
||||||
|
video: 'retain-on-failure'
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
storageState: path.join(__dirname, 'tests/e2e/.auth/admin.json')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: `http://localhost:${DASHBOARD_PORT}/admin/`,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe',
|
||||||
|
timeout: 60_000
|
||||||
|
}
|
||||||
|
});
|
||||||
256
dashboard/src/lib/ActionMenu.svelte
Normal file
256
dashboard/src/lib/ActionMenu.svelte
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
<!--
|
||||||
|
Per-row "⋮" kebab menu. Hides secondary actions (edit, deactivate,
|
||||||
|
delete, etc.) behind a single trigger so list rows stay tidy.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
<ActionMenu
|
||||||
|
items={[
|
||||||
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
|
{ label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
|
onClick: () => toggleActive(row) },
|
||||||
|
{ label: 'Delete', danger: true, onClick: () => openDelete(row),
|
||||||
|
disabled: !canDelete(row) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
Closes on: item click, click outside, ESC, scroll/resize. Keyboard:
|
||||||
|
Enter/Space opens; Up/Down navigate; Enter activates; ESC closes and
|
||||||
|
re-focuses the trigger. The popover is absolutely positioned relative
|
||||||
|
to the trigger and right-anchored — the parent must allow overflow
|
||||||
|
(`overflow: visible`) for it to extend past the row.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
export interface MenuItem {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
danger?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
items: MenuItem[];
|
||||||
|
/** Accessible label for the trigger button. */
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { items, label = 'More actions' }: Props = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let triggerEl = $state<HTMLButtonElement | null>(null);
|
||||||
|
let menuEl = $state<HTMLDivElement | null>(null);
|
||||||
|
let activeIndex = $state(-1);
|
||||||
|
|
||||||
|
let enabledIndices = $derived(
|
||||||
|
items
|
||||||
|
.map((it, i) => (it.disabled ? -1 : i))
|
||||||
|
.filter((i) => i >= 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
open ? close() : openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMenu() {
|
||||||
|
open = true;
|
||||||
|
activeIndex = enabledIndices[0] ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(refocus = false) {
|
||||||
|
open = false;
|
||||||
|
activeIndex = -1;
|
||||||
|
if (refocus) triggerEl?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(index: number) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item || item.disabled) return;
|
||||||
|
close();
|
||||||
|
item.onClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveActive(step: 1 | -1) {
|
||||||
|
if (enabledIndices.length === 0) return;
|
||||||
|
const cur = enabledIndices.indexOf(activeIndex);
|
||||||
|
const next =
|
||||||
|
cur === -1
|
||||||
|
? enabledIndices[0]
|
||||||
|
: enabledIndices[(cur + step + enabledIndices.length) % enabledIndices.length];
|
||||||
|
activeIndex = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTriggerKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!open) openMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMenuKeydown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(1);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
moveActive(-1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
if (activeIndex >= 0) activate(activeIndex);
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault();
|
||||||
|
close(true);
|
||||||
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowMouseDown(e: MouseEvent) {
|
||||||
|
if (!open) return;
|
||||||
|
const target = e.target as Node;
|
||||||
|
if (menuEl?.contains(target) || triggerEl?.contains(target)) return;
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on viewport changes — naive but enough; without a portal a
|
||||||
|
// scrolling list would otherwise leave the popover drifting away from
|
||||||
|
// its row.
|
||||||
|
function onViewportChange() {
|
||||||
|
if (open) close();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
onmousedown={onWindowMouseDown}
|
||||||
|
onscroll={onViewportChange}
|
||||||
|
onresize={onViewportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<button
|
||||||
|
bind:this={triggerEl}
|
||||||
|
type="button"
|
||||||
|
class="trigger"
|
||||||
|
class:open
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
onclick={toggle}
|
||||||
|
onkeydown={onTriggerKeydown}
|
||||||
|
>
|
||||||
|
<!-- vertical ellipsis ⋮ — kept inline as text so it inherits color -->
|
||||||
|
<span aria-hidden="true">⋮</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if open}
|
||||||
|
<div
|
||||||
|
bind:this={menuEl}
|
||||||
|
class="menu"
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
onkeydown={onMenuKeydown}
|
||||||
|
>
|
||||||
|
{#each items as item, i (i)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
class="item"
|
||||||
|
class:danger={item.danger}
|
||||||
|
class:active={i === activeIndex}
|
||||||
|
disabled={item.disabled}
|
||||||
|
onclick={() => activate(i)}
|
||||||
|
onmouseenter={() => {
|
||||||
|
if (!item.disabled) activeIndex = i;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover,
|
||||||
|
.trigger:focus-visible,
|
||||||
|
.trigger.open {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #334155;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
min-width: 9rem;
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 10px 25px -10px rgba(0, 0, 0, 0.6);
|
||||||
|
padding: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.active:not(:disabled) {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item.danger.active:not(:disabled) {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -25,12 +25,18 @@
|
|||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
language = 'rhai' as Language,
|
language = 'rhai' as Language,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
minHeight = '12rem'
|
minHeight = '12rem',
|
||||||
|
readOnly = false
|
||||||
}: {
|
}: {
|
||||||
value?: string;
|
value?: string;
|
||||||
language?: Language;
|
language?: Language;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
|
/** When true the editor renders without a cursor and rejects
|
||||||
|
* keystrokes. Parent-driven `value` changes still apply via
|
||||||
|
* the dispatch path below — this only blocks user edits.
|
||||||
|
* Not reactive after mount; re-mount via `{#key}` if needed. */
|
||||||
|
readOnly?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let host: HTMLDivElement | null = null;
|
let host: HTMLDivElement | null = null;
|
||||||
@@ -48,6 +54,12 @@
|
|||||||
keymap.of([indentWithTab]),
|
keymap.of([indentWithTab]),
|
||||||
dashboardSyntaxHighlighting,
|
dashboardSyntaxHighlighting,
|
||||||
dashboardTheme,
|
dashboardTheme,
|
||||||
|
// readOnly + editable together: readOnly blocks the
|
||||||
|
// underlying transactions, editable suppresses the caret
|
||||||
|
// + selection visuals so the user can see it's not
|
||||||
|
// editable.
|
||||||
|
EditorState.readOnly.of(readOnly),
|
||||||
|
EditorView.editable.of(!readOnly),
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged && !pushingFromOutside) {
|
if (update.docChanged && !pushingFromOutside) {
|
||||||
value = update.state.doc.toString();
|
value = update.state.doc.toString();
|
||||||
|
|||||||
69
dashboard/src/lib/RoleChip.svelte
Normal file
69
dashboard/src/lib/RoleChip.svelte
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InstanceRole } from '$lib/auth';
|
||||||
|
import type { AppRole } from '$lib/api';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role?: InstanceRole;
|
||||||
|
appRole?: AppRole;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<span class="chip {cls}" class:sm={size === 'sm'}>{label}</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.chip.sm {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
}
|
||||||
|
.chip-owner {
|
||||||
|
background: #78350f;
|
||||||
|
color: #fbbf24;
|
||||||
|
border-color: #b45309;
|
||||||
|
}
|
||||||
|
.chip-admin {
|
||||||
|
background: #164e63;
|
||||||
|
color: #67e8f9;
|
||||||
|
border-color: #0e7490;
|
||||||
|
}
|
||||||
|
.chip-member {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
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>
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { clearSession, getToken, setSession, type AdminUser } from './auth';
|
import { clearSession, getToken, setSession, type InstanceRole } from './auth';
|
||||||
|
|
||||||
|
export type { InstanceRole };
|
||||||
|
|
||||||
export interface ScriptSandbox {
|
export interface ScriptSandbox {
|
||||||
max_operations?: number;
|
max_operations?: number;
|
||||||
@@ -42,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 {
|
||||||
@@ -62,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 {
|
||||||
@@ -232,10 +241,42 @@ function safeJson(text: string): unknown {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserRecord {
|
export type Scope =
|
||||||
|
| 'script:read'
|
||||||
|
| 'script:write'
|
||||||
|
| 'route:write'
|
||||||
|
| 'domain:manage'
|
||||||
|
| 'log:read'
|
||||||
|
| 'app:admin'
|
||||||
|
| 'instance:admin';
|
||||||
|
|
||||||
|
export const ALL_SCOPES: readonly Scope[] = [
|
||||||
|
'script:read',
|
||||||
|
'script:write',
|
||||||
|
'route:write',
|
||||||
|
'domain:manage',
|
||||||
|
'log:read',
|
||||||
|
'app:admin',
|
||||||
|
'instance:admin'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function isInstanceScope(s: Scope): boolean {
|
||||||
|
return s.startsWith('instance:');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeDto {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminDto {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
last_login_at: string | null;
|
last_login_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -243,16 +284,57 @@ export interface AdminUserRecord {
|
|||||||
export interface CreateAdminInput {
|
export interface CreateAdminInput {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
email?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatchAdminInput {
|
export interface PatchAdminInput {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
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 {
|
||||||
|
id: string;
|
||||||
|
prefix: string;
|
||||||
|
name: string;
|
||||||
|
scopes: Scope[];
|
||||||
|
app_id: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
last_used_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintApiKeyInput {
|
||||||
|
name: string;
|
||||||
|
scopes: Scope[];
|
||||||
|
app_id?: string | null;
|
||||||
|
expires_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MintApiKeyResponse extends ApiKeyDto {
|
||||||
|
raw_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
user: AdminUser;
|
user: MeDto;
|
||||||
token: string;
|
token: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
}
|
}
|
||||||
@@ -263,7 +345,7 @@ export const api = {
|
|||||||
version: () => adminRequest<VersionInfo>('/version'),
|
version: () => adminRequest<VersionInfo>('/version'),
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
login: async (username: string, password: string): Promise<AdminUser> => {
|
login: async (username: string, password: string): Promise<MeDto> => {
|
||||||
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
const r = await adminRequest<LoginResponse>('/api/v1/admin/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password })
|
body: JSON.stringify({ username, password })
|
||||||
@@ -282,19 +364,19 @@ export const api = {
|
|||||||
clearSession();
|
clearSession();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
me: () => adminRequest<AdminUser>('/api/v1/admin/auth/me')
|
me: () => adminRequest<MeDto>('/api/v1/admin/auth/me')
|
||||||
},
|
},
|
||||||
|
|
||||||
admins: {
|
admins: {
|
||||||
list: () => adminRequest<AdminUserRecord[]>('/api/v1/admin/admins'),
|
list: () => adminRequest<AdminDto[]>('/api/v1/admin/admins'),
|
||||||
get: (id: string) => adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`),
|
get: (id: string) => adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`),
|
||||||
create: (input: CreateAdminInput) =>
|
create: (input: CreateAdminInput) =>
|
||||||
adminRequest<AdminUserRecord>('/api/v1/admin/admins', {
|
adminRequest<AdminDto>('/api/v1/admin/admins', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
update: (id: string, input: PatchAdminInput) =>
|
update: (id: string, input: PatchAdminInput) =>
|
||||||
adminRequest<AdminUserRecord>(`/api/v1/admin/admins/${id}`, {
|
adminRequest<AdminDto>(`/api/v1/admin/admins/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(input)
|
body: JSON.stringify(input)
|
||||||
}),
|
}),
|
||||||
@@ -302,6 +384,17 @@ export const api = {
|
|||||||
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
adminRequest<null>(`/api/v1/admin/admins/${id}`, { method: 'DELETE' })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
apiKeys: {
|
||||||
|
list: () => adminRequest<ApiKeyDto[]>('/api/v1/admin/api-keys'),
|
||||||
|
mint: (input: MintApiKeyInput) =>
|
||||||
|
adminRequest<MintApiKeyResponse>('/api/v1/admin/api-keys', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
}),
|
||||||
|
revoke: (id: string) =>
|
||||||
|
adminRequest<null>(`/api/v1/admin/api-keys/${id}`, { method: 'DELETE' })
|
||||||
|
},
|
||||||
|
|
||||||
routes: {
|
routes: {
|
||||||
listForScript: (scriptId: string) =>
|
listForScript: (scriptId: string) =>
|
||||||
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
adminRequest<Route[]>(`/api/v1/admin/scripts/${scriptId}/routes`),
|
||||||
@@ -401,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,
|
||||||
|
|||||||
@@ -10,9 +10,13 @@
|
|||||||
import { writable, get } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type InstanceRole = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
email: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_KEY = 'picloud.admin.token';
|
const TOKEN_KEY = 'picloud.admin.token';
|
||||||
|
|||||||
60
dashboard/src/lib/capabilities.test.ts
Normal file
60
dashboard/src/lib/capabilities.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { AppRole, MeDto } from './api';
|
||||||
|
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
|
||||||
|
|
||||||
|
function me(role: MeDto['instance_role']): MeDto {
|
||||||
|
return { id: 'u', username: 'u', instance_role: role, email: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
|
||||||
|
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
|
||||||
|
|
||||||
|
describe('capabilities', () => {
|
||||||
|
it('null caller is denied everything', () => {
|
||||||
|
expect(canCreateApp(null)).toBe(false);
|
||||||
|
expect(canManageUsers(null)).toBe(false);
|
||||||
|
expect(canWriteApp(null, 'app_admin')).toBe(false);
|
||||||
|
expect(canAdminApp(null, 'app_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
|
||||||
|
expect(canCreateApp(me('owner'))).toBe(true);
|
||||||
|
expect(canCreateApp(me('admin'))).toBe(true);
|
||||||
|
expect(canCreateApp(me('member'))).toBe(false);
|
||||||
|
expect(canManageUsers(me('owner'))).toBe(true);
|
||||||
|
expect(canManageUsers(me('admin'))).toBe(true);
|
||||||
|
expect(canManageUsers(me('member'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner + admin can write and admin every app regardless of my_role', () => {
|
||||||
|
for (const role of ['owner', 'admin'] as const) {
|
||||||
|
for (const appRole of APP_ROLES) {
|
||||||
|
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||||
|
expect(canAdminApp(me(role), appRole)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('member: write requires app_admin or editor; admin requires app_admin', () => {
|
||||||
|
const m = me('member');
|
||||||
|
expect(canWriteApp(m, 'app_admin')).toBe(true);
|
||||||
|
expect(canWriteApp(m, 'editor')).toBe(true);
|
||||||
|
expect(canWriteApp(m, 'viewer')).toBe(false);
|
||||||
|
expect(canWriteApp(m, null)).toBe(false);
|
||||||
|
|
||||||
|
expect(canAdminApp(m, 'app_admin')).toBe(true);
|
||||||
|
expect(canAdminApp(m, 'editor')).toBe(false);
|
||||||
|
expect(canAdminApp(m, 'viewer')).toBe(false);
|
||||||
|
expect(canAdminApp(m, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('canAdminApp implies canWriteApp for every combination', () => {
|
||||||
|
for (const role of ROLES) {
|
||||||
|
for (const appRole of APP_ROLES) {
|
||||||
|
if (canAdminApp(me(role), appRole)) {
|
||||||
|
expect(canWriteApp(me(role), appRole)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
dashboard/src/lib/capabilities.ts
Normal file
43
dashboard/src/lib/capabilities.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Permission predicates the dashboard uses to shadow create / edit /
|
||||||
|
// delete affordances. Mirrors the canonical role → capability rules in
|
||||||
|
// crates/manager-core/src/authz.rs:
|
||||||
|
//
|
||||||
|
// owner / admin instance role → implicit app_admin on every app
|
||||||
|
// app_admin → settings, domain claims, delete app, delete scripts
|
||||||
|
// editor → CRUD on scripts, routes, sandbox config (no script delete)
|
||||||
|
// viewer → read scripts + execution logs
|
||||||
|
// member with no membership → no access
|
||||||
|
//
|
||||||
|
// These helpers are read-only and have no Svelte runes — callers pass
|
||||||
|
// the current `MeDto` and (when relevant) the per-app `my_role` they
|
||||||
|
// already hold. Hiding here never authorizes anything; the backend's
|
||||||
|
// `require(Capability::…)` is always the ground truth.
|
||||||
|
|
||||||
|
import type { AppRole, MeDto } from './api';
|
||||||
|
|
||||||
|
/** Owner + admin only. Members never see "New app". */
|
||||||
|
export function canCreateApp(me: MeDto | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner + admin only — the "Users" admin page is also gated this way. */
|
||||||
|
export function canManageUsers(me: MeDto | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
return me.instance_role === 'owner' || me.instance_role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can mutate scripts and routes (Save, +Add route, remove route). */
|
||||||
|
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||||
|
return appMyRole === 'app_admin' || appMyRole === 'editor';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can take app-admin actions: app settings, domain claims, delete
|
||||||
|
* app, delete scripts, manage members. */
|
||||||
|
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
|
||||||
|
if (!me) return false;
|
||||||
|
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
|
||||||
|
return appMyRole === 'app_admin';
|
||||||
|
}
|
||||||
54
dashboard/src/lib/password-gen.test.ts
Normal file
54
dashboard/src/lib/password-gen.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { generatePassword } from './password-gen';
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
describe('generatePassword', () => {
|
||||||
|
it('rejects lengths under 8', () => {
|
||||||
|
expect(() => generatePassword(7)).toThrowError(/at least 8/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the requested length', () => {
|
||||||
|
for (const len of [8, 16, 32, 64]) {
|
||||||
|
expect(generatePassword(len)).toHaveLength(len);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only characters from the documented charset', () => {
|
||||||
|
const set = new Set(CHARSET);
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
for (const c of generatePassword(32)) {
|
||||||
|
expect(set.has(c)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rejection-sampling sanity. With N = 71 the expected count per
|
||||||
|
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
|
||||||
|
// any byte-level bias (biased modulo would push the first 38
|
||||||
|
// chars by ~16 ppm — too small for this band to flag on its
|
||||||
|
// own, but a regression to `% N` over Uint16/Uint32 with a
|
||||||
|
// non-power-of-two charset would still produce visible drift in
|
||||||
|
// pathological codepaths). Mostly this guards against
|
||||||
|
// fundamental mistakes (off-by-one in the loop, returning the
|
||||||
|
// same byte stream every time, etc.).
|
||||||
|
it('distribution stays within a wide tolerance band', () => {
|
||||||
|
const samples = 100_000;
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (let i = 0; i < samples; i++) {
|
||||||
|
const c = generatePassword(8)[0];
|
||||||
|
counts.set(c, (counts.get(c) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const expected = samples / CHARSET.length;
|
||||||
|
const sigma = Math.sqrt(expected);
|
||||||
|
const band = 6 * sigma;
|
||||||
|
for (const c of CHARSET) {
|
||||||
|
const observed = counts.get(c) ?? 0;
|
||||||
|
const drift = Math.abs(observed - expected);
|
||||||
|
expect(
|
||||||
|
drift,
|
||||||
|
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
|
||||||
|
).toBeLessThan(band);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
37
dashboard/src/lib/password-gen.ts
Normal file
37
dashboard/src/lib/password-gen.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Cryptographically random password generator for the user-create
|
||||||
|
// and reset-password flows. PiCloud has no email yet, so the admin
|
||||||
|
// invites a user by generating a password locally, posting it to the
|
||||||
|
// backend, and copying the cleartext out of the one-time reveal panel
|
||||||
|
// to share through whatever channel they trust.
|
||||||
|
//
|
||||||
|
// Charset is alphanumeric plus a small printable symbol set — enough
|
||||||
|
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
|
||||||
|
// avoidant of characters that ship awkwardly through chat clients
|
||||||
|
// (no quotes, slashes, or backticks).
|
||||||
|
//
|
||||||
|
// Sampling: rejection sampling against a Uint8 stream. The naive
|
||||||
|
// `byte % CHARSET.length` would slightly overweight the first
|
||||||
|
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
|
||||||
|
// safe at 16 chars but easy to remove.
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
export function generatePassword(length = 16): string {
|
||||||
|
if (length < 8) {
|
||||||
|
throw new Error('password length must be at least 8');
|
||||||
|
}
|
||||||
|
const n = CHARSET.length;
|
||||||
|
// Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
|
||||||
|
// rejected to remove modulo bias.
|
||||||
|
const max = 256 - (256 % n);
|
||||||
|
const buf = new Uint8Array(length);
|
||||||
|
let out = '';
|
||||||
|
while (out.length < length) {
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
for (let i = 0; i < buf.length && out.length < length; i++) {
|
||||||
|
const byte = buf[i];
|
||||||
|
if (byte < max) out += CHARSET[byte % n];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { currentUser, getToken } from '$lib/auth';
|
import { currentUser, getToken } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -46,12 +47,17 @@
|
|||||||
<a href={base + '/'} class="brand">PiCloud</a>
|
<a href={base + '/'} class="brand">PiCloud</a>
|
||||||
<nav>
|
<nav>
|
||||||
<a href={base + '/apps'}>Apps</a>
|
<a href={base + '/apps'}>Apps</a>
|
||||||
<a href={base + '/admins'}>Admins</a>
|
{#if user && user.instance_role !== 'member'}
|
||||||
|
<a href={base + '/users'}>Users</a>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
{#if user}
|
{#if user}
|
||||||
<div class="usermenu">
|
<div class="usermenu">
|
||||||
|
<a href={base + '/profile'} class="profile-chip" title="View profile">
|
||||||
|
<RoleChip role={user.instance_role} size="sm" />
|
||||||
<span class="username">{user.username}</span>
|
<span class="username">{user.username}</span>
|
||||||
|
</a>
|
||||||
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
<button type="button" class="logout" onclick={handleLogout}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -121,6 +127,20 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.profile-chip:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
color: #cbd5e1;
|
color: #cbd5e1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,687 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { base } from '$app/paths';
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { api, ApiError, type AdminUserRecord } from '$lib/api';
|
|
||||||
import { currentUser } from '$lib/auth';
|
|
||||||
|
|
||||||
let admins = $state<AdminUserRecord[]>([]);
|
|
||||||
let loadError = $state<string | null>(null);
|
|
||||||
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
|
||||||
|
|
||||||
const me = $derived($currentUser);
|
|
||||||
|
|
||||||
let createOpen = $state(false);
|
|
||||||
let createForm = $state({ username: '', password: '', confirm: '' });
|
|
||||||
let createPending = $state(false);
|
|
||||||
let createError = $state<string | null>(null);
|
|
||||||
|
|
||||||
let passwordTarget = $state<AdminUserRecord | null>(null);
|
|
||||||
let passwordForm = $state({ password: '', confirm: '' });
|
|
||||||
let passwordPending = $state(false);
|
|
||||||
let passwordError = $state<string | null>(null);
|
|
||||||
|
|
||||||
let deleteTarget = $state<AdminUserRecord | null>(null);
|
|
||||||
let deletePending = $state(false);
|
|
||||||
|
|
||||||
let actionsOpenFor = $state<string | null>(null);
|
|
||||||
|
|
||||||
onMount(refresh);
|
|
||||||
|
|
||||||
async function refresh() {
|
|
||||||
loadError = null;
|
|
||||||
try {
|
|
||||||
admins = await api.admins.list();
|
|
||||||
} catch (e) {
|
|
||||||
loadError = e instanceof ApiError ? e.message : 'failed to load admins';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function flash(kind: 'error' | 'info', message: string) {
|
|
||||||
banner = { kind, message };
|
|
||||||
setTimeout(() => {
|
|
||||||
if (banner?.message === message) banner = null;
|
|
||||||
}, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openCreate() {
|
|
||||||
createForm = { username: '', password: '', confirm: '' };
|
|
||||||
createError = null;
|
|
||||||
createOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitCreate(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
createError = null;
|
|
||||||
if (createForm.password !== createForm.confirm) {
|
|
||||||
createError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
createPending = true;
|
|
||||||
try {
|
|
||||||
await api.admins.create({
|
|
||||||
username: createForm.username.trim(),
|
|
||||||
password: createForm.password
|
|
||||||
});
|
|
||||||
createOpen = false;
|
|
||||||
await refresh();
|
|
||||||
flash('info', `Created admin "${createForm.username.trim()}".`);
|
|
||||||
} catch (e) {
|
|
||||||
createError = e instanceof ApiError ? e.message : 'failed to create admin';
|
|
||||||
} finally {
|
|
||||||
createPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPassword(row: AdminUserRecord) {
|
|
||||||
passwordTarget = row;
|
|
||||||
passwordForm = { password: '', confirm: '' };
|
|
||||||
passwordError = null;
|
|
||||||
actionsOpenFor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitPassword(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!passwordTarget) return;
|
|
||||||
passwordError = null;
|
|
||||||
if (passwordForm.password !== passwordForm.confirm) {
|
|
||||||
passwordError = 'Passwords do not match';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
passwordPending = true;
|
|
||||||
try {
|
|
||||||
await api.admins.update(passwordTarget.id, { password: passwordForm.password });
|
|
||||||
const name = passwordTarget.username;
|
|
||||||
passwordTarget = null;
|
|
||||||
flash('info', `Password updated for "${name}".`);
|
|
||||||
} catch (e) {
|
|
||||||
passwordError = e instanceof ApiError ? e.message : 'failed to update password';
|
|
||||||
} finally {
|
|
||||||
passwordPending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleActive(row: AdminUserRecord) {
|
|
||||||
actionsOpenFor = null;
|
|
||||||
try {
|
|
||||||
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
|
||||||
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
|
||||||
flash('info', `${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`);
|
|
||||||
} catch (e) {
|
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to update admin');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDelete(row: AdminUserRecord) {
|
|
||||||
deleteTarget = row;
|
|
||||||
actionsOpenFor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDelete() {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deletePending = true;
|
|
||||||
const target = deleteTarget;
|
|
||||||
try {
|
|
||||||
await api.admins.remove(target.id);
|
|
||||||
deleteTarget = null;
|
|
||||||
if (me && me.id === target.id) {
|
|
||||||
// Just deleted ourselves — sign out and bounce.
|
|
||||||
await api.auth.logout();
|
|
||||||
await goto(`${base}/login`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await refresh();
|
|
||||||
flash('info', `Deleted "${target.username}".`);
|
|
||||||
} catch (e) {
|
|
||||||
flash('error', e instanceof ApiError ? e.message : 'failed to delete admin');
|
|
||||||
} finally {
|
|
||||||
deletePending = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleActions(id: string) {
|
|
||||||
actionsOpenFor = actionsOpenFor === id ? null : id;
|
|
||||||
}
|
|
||||||
|
|
||||||
function relative(iso: string | null): string {
|
|
||||||
if (!iso) return 'Never';
|
|
||||||
const then = new Date(iso).getTime();
|
|
||||||
const now = Date.now();
|
|
||||||
const sec = Math.round((now - then) / 1000);
|
|
||||||
if (sec < 60) return `${sec} second${sec === 1 ? '' : 's'} ago`;
|
|
||||||
const min = Math.round(sec / 60);
|
|
||||||
if (min < 60) return `${min} minute${min === 1 ? '' : 's'} ago`;
|
|
||||||
const hr = Math.round(min / 60);
|
|
||||||
if (hr < 24) return `${hr} hour${hr === 1 ? '' : 's'} ago`;
|
|
||||||
const day = Math.round(hr / 24);
|
|
||||||
if (day === 1) return 'Yesterday';
|
|
||||||
if (day < 7) return `${day} days ago`;
|
|
||||||
return new Date(iso).toLocaleDateString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function absolute(iso: string | null): string {
|
|
||||||
return iso ? new Date(iso).toISOString() : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortDate(iso: string): string {
|
|
||||||
return new Date(iso).toISOString().slice(0, 10);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<header class="head">
|
|
||||||
<h1>Admin Users</h1>
|
|
||||||
<button type="button" class="primary" onclick={openCreate}>+ New admin user</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{#if banner}
|
|
||||||
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loadError}
|
|
||||||
<div class="error">
|
|
||||||
{loadError}
|
|
||||||
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
|
||||||
</div>
|
|
||||||
{:else if admins.length === 0}
|
|
||||||
<p class="empty">No admin users yet. Add one to get started.</p>
|
|
||||||
{:else}
|
|
||||||
<div class="table">
|
|
||||||
<div class="row head-row">
|
|
||||||
<div>Username</div>
|
|
||||||
<div>Status</div>
|
|
||||||
<div>Created</div>
|
|
||||||
<div>Last login</div>
|
|
||||||
<div class="actions-col"></div>
|
|
||||||
</div>
|
|
||||||
{#each admins as row (row.id)}
|
|
||||||
<div class="row">
|
|
||||||
<div class="username-cell">
|
|
||||||
<span class="name">{row.username}</span>
|
|
||||||
{#if me && me.id === row.id}
|
|
||||||
<span class="you-tag">(you)</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{#if row.is_active}
|
|
||||||
<span class="status status-active">● Active</span>
|
|
||||||
{:else}
|
|
||||||
<span class="status status-inactive">○ Inactive</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div>{shortDate(row.created_at)}</div>
|
|
||||||
<div title={absolute(row.last_login_at)}>{relative(row.last_login_at)}</div>
|
|
||||||
<div class="actions-col">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="kebab"
|
|
||||||
aria-label="Actions for {row.username}"
|
|
||||||
onclick={() => toggleActions(row.id)}
|
|
||||||
>
|
|
||||||
⋮
|
|
||||||
</button>
|
|
||||||
{#if actionsOpenFor === row.id}
|
|
||||||
<div class="menu">
|
|
||||||
<button type="button" onclick={() => openPassword(row)}>Change password</button>
|
|
||||||
<button type="button" onclick={() => toggleActive(row)}>
|
|
||||||
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="danger" onclick={() => openDelete(row)}>Delete</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- New admin modal -->
|
|
||||||
{#if createOpen}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) createOpen = false;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form class="modal" onsubmit={submitCreate}>
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>New admin user</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="x"
|
|
||||||
aria-label="Close"
|
|
||||||
onclick={() => (createOpen = false)}>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>Username</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
bind:value={createForm.username}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>Lowercase letters, digits, . _ -</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={createForm.password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<small>Minimum 8 characters</small>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Confirm password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={createForm.confirm}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if createError}
|
|
||||||
<div class="error">{createError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (createOpen = false)}>Cancel</button>
|
|
||||||
<button type="submit" class="primary" disabled={createPending}>
|
|
||||||
{createPending ? 'Creating…' : 'Create user'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Change password modal -->
|
|
||||||
{#if passwordTarget}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) passwordTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<form class="modal" onsubmit={submitPassword}>
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Change password — {passwordTarget.username}</h2>
|
|
||||||
<button type="button" class="x" aria-label="Close" onclick={() => (passwordTarget = null)}
|
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<label>
|
|
||||||
<span>New password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={passwordForm.password}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span>Confirm password</span>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
bind:value={passwordForm.confirm}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if passwordError}
|
|
||||||
<div class="error">{passwordError}</div>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (passwordTarget = null)}>Cancel</button>
|
|
||||||
<button type="submit" class="primary" disabled={passwordPending}>
|
|
||||||
{passwordPending ? 'Updating…' : 'Update'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Delete confirmation modal -->
|
|
||||||
{#if deleteTarget}
|
|
||||||
<div
|
|
||||||
class="modal-backdrop"
|
|
||||||
role="presentation"
|
|
||||||
onclick={(e) => {
|
|
||||||
if (e.target === e.currentTarget) deleteTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="modal">
|
|
||||||
<div class="modal-head">
|
|
||||||
<h2>Delete {deleteTarget.username}?</h2>
|
|
||||||
<button type="button" class="x" aria-label="Close" onclick={() => (deleteTarget = null)}
|
|
||||||
>✕</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{#if me && me.id === deleteTarget.id}
|
|
||||||
<p>
|
|
||||||
You are about to delete <strong>your own</strong> account. You will be signed out immediately
|
|
||||||
and will not be able to sign back in with these credentials.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p>
|
|
||||||
This permanently removes <strong>{deleteTarget.username}</strong> and all their sessions.
|
|
||||||
This cannot be undone.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button type="button" class="ghost" onclick={() => (deleteTarget = null)}>Cancel</button>
|
|
||||||
<button type="button" class="danger" disabled={deletePending} onclick={confirmDelete}>
|
|
||||||
{deletePending ? 'Deleting…' : 'Delete'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
margin: 0;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner {
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.banner-error {
|
|
||||||
background: #450a0a;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
.banner-info {
|
|
||||||
background: #0c2a36;
|
|
||||||
border: 1px solid #155e75;
|
|
||||||
color: #a5f3fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: #64748b;
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0;
|
|
||||||
border: 1px dashed #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
overflow: visible;
|
|
||||||
background: #0b1220;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1.5fr 0.9fr 1fr 1.2fr 3rem;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-bottom: 1px solid #1e293b;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.head-row {
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
background: #0f172a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username-cell {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
color: #e2e8f0;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.you-tag {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
.status-active {
|
|
||||||
color: #34d399;
|
|
||||||
}
|
|
||||||
.status-inactive {
|
|
||||||
color: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions-col {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kebab {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #94a3b8;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
.kebab:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
position: absolute;
|
|
||||||
top: 100%;
|
|
||||||
right: 0;
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-width: 12rem;
|
|
||||||
z-index: 10;
|
|
||||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu button {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #cbd5e1;
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.menu button:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
.menu button.danger {
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
.menu button.danger:hover {
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fecaca;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
background: #450a0a;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.retry {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #b91c1c;
|
|
||||||
color: #fecaca;
|
|
||||||
padding: 0.25rem 0.6rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.primary {
|
|
||||||
background: #38bdf8;
|
|
||||||
color: #0b1220;
|
|
||||||
border: none;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.primary:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.ghost {
|
|
||||||
background: transparent;
|
|
||||||
color: #94a3b8;
|
|
||||||
border: 1px solid #334155;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.ghost:hover {
|
|
||||||
background: #1e293b;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button.danger {
|
|
||||||
background: #b91c1c;
|
|
||||||
color: #fef2f2;
|
|
||||||
border: none;
|
|
||||||
padding: 0.55rem 0.9rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
button.danger:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(15, 23, 42, 0.7);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal {
|
|
||||||
background: #0b1220;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
min-width: 24rem;
|
|
||||||
max-width: 28rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.x {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.x:hover {
|
|
||||||
color: #e2e8f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.4rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #cbd5e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal label small {
|
|
||||||
color: #64748b;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal input {
|
|
||||||
background: #0f172a;
|
|
||||||
color: #e2e8f0;
|
|
||||||
border: 1px solid #1e293b;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.modal input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #38bdf8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
color: #cbd5e1;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { api, ApiError, type App } from '$lib/api';
|
import { api, ApiError, type App } from '$lib/api';
|
||||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||||
|
import { canCreateApp } from '$lib/capabilities';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const canCreate = $derived(canCreateApp(me));
|
||||||
|
|
||||||
let apps = $state<App[] | null>(null);
|
let apps = $state<App[] | null>(null);
|
||||||
let listError = $state<string | null>(null);
|
let listError = $state<string | null>(null);
|
||||||
@@ -99,6 +104,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>Apps</h1>
|
<h1>Apps</h1>
|
||||||
|
{#if canCreate}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -108,9 +114,10 @@
|
|||||||
>
|
>
|
||||||
{showCreate ? 'Cancel' : 'New app'}
|
{showCreate ? 'Cancel' : 'New app'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showCreate}
|
{#if showCreate && canCreate}
|
||||||
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -5,26 +5,44 @@
|
|||||||
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';
|
||||||
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||||||
|
|
||||||
|
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[]>([]);
|
||||||
|
|
||||||
|
// Derive UI gates from the capabilities helper so the rules stay
|
||||||
|
// in lockstep with the backend's `can()`. canAdminApp also covers
|
||||||
|
// the Members + Settings + Domains-mutation tabs; canWriteApp
|
||||||
|
// covers New script.
|
||||||
|
const canWrite = $derived(canWriteApp(me, myRole));
|
||||||
|
const canAdmin = $derived(canAdminApp(me, myRole));
|
||||||
|
|
||||||
// Script create
|
// Script create
|
||||||
let showCreateScript = $state(false);
|
let showCreateScript = $state(false);
|
||||||
@@ -55,6 +73,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 +103,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 (canAdmin) {
|
||||||
|
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 +137,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 +273,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;
|
||||||
@@ -226,6 +368,16 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
void loadApp();
|
void loadApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: a viewer / editor following a stale link to
|
||||||
|
// the Settings or Members tab gets bounced back to Scripts. The
|
||||||
|
// backend still 403s the underlying calls, but no point showing an
|
||||||
|
// empty tab.
|
||||||
|
$effect(() => {
|
||||||
|
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
|
||||||
|
activeTab = 'scripts';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading && !app}
|
{#if loading && !app}
|
||||||
@@ -258,26 +410,35 @@
|
|||||||
class:active={activeTab === 'domains'}
|
class:active={activeTab === 'domains'}
|
||||||
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
|
||||||
>
|
>
|
||||||
|
{#if canAdmin}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class:active={activeTab === 'members'}
|
||||||
|
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class:active={activeTab === 'settings'}
|
class:active={activeTab === 'settings'}
|
||||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||||
>
|
>
|
||||||
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if activeTab === 'scripts'}
|
{#if activeTab === 'scripts'}
|
||||||
<section>
|
<section>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<h2>Scripts</h2>
|
<h2>Scripts</h2>
|
||||||
|
{#if canWrite}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (showCreateScript = !showCreateScript)}
|
onclick={() => (showCreateScript = !showCreateScript)}
|
||||||
>
|
>
|
||||||
{showCreateScript ? 'Cancel' : 'New script'}
|
{showCreateScript ? 'Cancel' : 'New script'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateScript}
|
{#if showCreateScript && canWrite}
|
||||||
<form class="create-form" onsubmit={submitCreateScript}>
|
<form class="create-form" onsubmit={submitCreateScript}>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
@@ -330,6 +491,7 @@
|
|||||||
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
|
||||||
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
|
||||||
</p>
|
</p>
|
||||||
|
{#if canAdmin}
|
||||||
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||||
<input
|
<input
|
||||||
bind:value={createDomainPattern}
|
bind:value={createDomainPattern}
|
||||||
@@ -343,6 +505,7 @@
|
|||||||
{#if createDomainError}
|
{#if createDomainError}
|
||||||
<div class="error">{createDomainError}</div>
|
<div class="error">{createDomainError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
{#if domains.length === 0}
|
{#if domains.length === 0}
|
||||||
<p class="muted">No domain claims yet.</p>
|
<p class="muted">No domain claims yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -353,6 +516,7 @@
|
|||||||
<code>{d.pattern}</code>
|
<code>{d.pattern}</code>
|
||||||
<span class="muted">— {d.shape}</span>
|
<span class="muted">— {d.shape}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{#if canAdmin}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="secondary danger"
|
class="secondary danger"
|
||||||
@@ -360,12 +524,128 @@
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{:else if activeTab === 'settings'}
|
{:else if activeTab === 'members' && canAdmin}
|
||||||
|
<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' && canAdmin}
|
||||||
<section>
|
<section>
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
<form class="create-form" onsubmit={(e) => saveSettings(e)}>
|
||||||
@@ -502,6 +782,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 +1044,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>
|
||||||
|
|||||||
760
dashboard/src/routes/profile/+page.svelte
Normal file
760
dashboard/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
<!--
|
||||||
|
/admin/profile — every authenticated principal lands here for their
|
||||||
|
own identity + API-key management. No role gating: a member can mint
|
||||||
|
keys for the apps they belong to just like an admin can. Users-admin
|
||||||
|
actions live under /admin/users.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
ALL_SCOPES,
|
||||||
|
isInstanceScope,
|
||||||
|
type ApiKeyDto,
|
||||||
|
type App,
|
||||||
|
type MintApiKeyResponse,
|
||||||
|
type Scope
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
|
||||||
|
let keys = $state<ApiKeyDto[]>([]);
|
||||||
|
let apps = $state<App[]>([]);
|
||||||
|
let appBySlug = $derived(new Map(apps.map((a) => [a.id, a])));
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
// Surface the cross-page "access denied" notice when /users bounces
|
||||||
|
// a member back here. One-shot — clears as soon as the user
|
||||||
|
// navigates away or dismisses.
|
||||||
|
const deniedFromUsers = $derived(page.url.searchParams.get('denied') === 'users');
|
||||||
|
|
||||||
|
let mintOpen = $state(false);
|
||||||
|
let mintForm = $state<{
|
||||||
|
name: string;
|
||||||
|
scopes: Set<Scope>;
|
||||||
|
app_id: string | '';
|
||||||
|
expires_at: string;
|
||||||
|
}>({ name: '', scopes: new Set(), app_id: '', expires_at: '' });
|
||||||
|
let mintPending = $state(false);
|
||||||
|
let mintError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let reveal = $state<MintApiKeyResponse | null>(null);
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
let revokeTarget = $state<ApiKeyDto | null>(null);
|
||||||
|
let revokePending = $state(false);
|
||||||
|
|
||||||
|
const NAME_MAX = 64;
|
||||||
|
const scopeIsInstance = (s: Scope) => isInstanceScope(s);
|
||||||
|
const boundToApp = $derived(mintForm.app_id !== '');
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
mintForm.name.trim().length > 0 &&
|
||||||
|
mintForm.name.trim().length <= NAME_MAX &&
|
||||||
|
mintForm.scopes.size > 0 &&
|
||||||
|
!mintPending
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await Promise.all([refreshKeys(), loadApps()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshKeys() {
|
||||||
|
try {
|
||||||
|
keys = await api.apiKeys.list();
|
||||||
|
loadError = null;
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load API keys';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApps() {
|
||||||
|
try {
|
||||||
|
apps = await api.apps.list();
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: the form falls back to "no app options" and the
|
||||||
|
// list shows the bare UUID in the binding column.
|
||||||
|
apps = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMint() {
|
||||||
|
mintForm = { name: '', scopes: new Set(), app_id: '', expires_at: '' };
|
||||||
|
mintError = null;
|
||||||
|
mintOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelMint() {
|
||||||
|
mintOpen = false;
|
||||||
|
mintError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScope(s: Scope) {
|
||||||
|
const next = new Set(mintForm.scopes);
|
||||||
|
if (next.has(s)) next.delete(s);
|
||||||
|
else next.add(s);
|
||||||
|
mintForm = { ...mintForm, scopes: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the user binds the key to an app, instance:* scopes are
|
||||||
|
// mutually exclusive — drop them from the selection so submit
|
||||||
|
// doesn't 422.
|
||||||
|
$effect(() => {
|
||||||
|
if (!boundToApp) return;
|
||||||
|
const filtered = new Set<Scope>();
|
||||||
|
let dropped = false;
|
||||||
|
for (const s of mintForm.scopes) {
|
||||||
|
if (scopeIsInstance(s)) dropped = true;
|
||||||
|
else filtered.add(s);
|
||||||
|
}
|
||||||
|
if (dropped) {
|
||||||
|
mintForm = { ...mintForm, scopes: filtered };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitMint(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
mintPending = true;
|
||||||
|
mintError = null;
|
||||||
|
try {
|
||||||
|
const r = await api.apiKeys.mint({
|
||||||
|
name: mintForm.name.trim(),
|
||||||
|
scopes: Array.from(mintForm.scopes),
|
||||||
|
app_id: mintForm.app_id === '' ? null : mintForm.app_id,
|
||||||
|
expires_at: mintForm.expires_at === ''
|
||||||
|
? null
|
||||||
|
: new Date(mintForm.expires_at + 'T23:59:59Z').toISOString()
|
||||||
|
});
|
||||||
|
reveal = r;
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
mintOpen = false;
|
||||||
|
await refreshKeys();
|
||||||
|
} catch (e) {
|
||||||
|
mintError = e instanceof ApiError ? e.message : 'failed to mint API key';
|
||||||
|
} finally {
|
||||||
|
mintPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToken() {
|
||||||
|
if (!reveal) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(reveal.raw_token);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
reveal = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRevoke(key: ApiKeyDto) {
|
||||||
|
revokeTarget = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRevoke() {
|
||||||
|
if (!revokeTarget) return;
|
||||||
|
revokePending = true;
|
||||||
|
const target = revokeTarget;
|
||||||
|
try {
|
||||||
|
await api.apiKeys.revoke(target.id);
|
||||||
|
revokeTarget = null;
|
||||||
|
keys = keys.filter((k) => k.id !== target.id);
|
||||||
|
flash('info', `Revoked "${target.name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to revoke key');
|
||||||
|
} finally {
|
||||||
|
revokePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function appLabel(app_id: string | null): string {
|
||||||
|
if (!app_id) return 'Instance-wide';
|
||||||
|
const a = appBySlug.get(app_id);
|
||||||
|
return a ? a.slug : app_id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const sec = Math.round((Date.now() - then) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return shortDate(iso);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if me}
|
||||||
|
<section class="identity">
|
||||||
|
<div class="identity-head">
|
||||||
|
<h1>{me.username}</h1>
|
||||||
|
<RoleChip role={me.instance_role} />
|
||||||
|
</div>
|
||||||
|
<dl class="identity-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Email</dt>
|
||||||
|
<dd>{me.email ?? 'No email set'}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>User ID</dt>
|
||||||
|
<dd class="mono">{me.id}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if deniedFromUsers}
|
||||||
|
<div class="banner banner-info">
|
||||||
|
You don't have access to the Users page. Ask an admin if you need to manage users.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="keys-section">
|
||||||
|
<header class="section-head">
|
||||||
|
<h2>API keys</h2>
|
||||||
|
{#if !mintOpen && !reveal}
|
||||||
|
<button type="button" class="primary" onclick={openMint}>+ Mint API key</button>
|
||||||
|
{/if}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if reveal}
|
||||||
|
<div class="reveal">
|
||||||
|
<h3>Save this token now — it will never be shown again.</h3>
|
||||||
|
<p class="reveal-sub">
|
||||||
|
Paste it into your CLI config or external integration. PiCloud only ever stores a hash; if
|
||||||
|
you lose it, mint a new one.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{reveal.raw_token}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyToken}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've saved this token somewhere safe.</span>
|
||||||
|
</label>
|
||||||
|
<div class="reveal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if mintOpen}
|
||||||
|
<form class="mint" onsubmit={submitMint}>
|
||||||
|
<div class="form-row">
|
||||||
|
<label class="field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={mintForm.name}
|
||||||
|
maxlength={NAME_MAX}
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="e.g. ci-deploy"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>1–{NAME_MAX} chars. Only you see it.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Binding</span>
|
||||||
|
<select bind:value={mintForm.app_id}>
|
||||||
|
<option value="">Instance-wide</option>
|
||||||
|
{#each apps as a (a.id)}
|
||||||
|
<option value={a.id}>{a.slug} ({a.name})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>Pick an app to scope this key, or leave instance-wide.</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>Expires</span>
|
||||||
|
<input type="date" bind:value={mintForm.expires_at} />
|
||||||
|
<small>Leave blank for no expiry.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="scopes">
|
||||||
|
<legend>Scopes</legend>
|
||||||
|
<div class="scope-grid">
|
||||||
|
{#each ALL_SCOPES as scope (scope)}
|
||||||
|
{@const instanceScope = scopeIsInstance(scope)}
|
||||||
|
{@const disabled = boundToApp && instanceScope}
|
||||||
|
<label
|
||||||
|
class="scope-chip"
|
||||||
|
class:disabled
|
||||||
|
title={disabled ? "Bound keys can't carry instance scopes" : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={mintForm.scopes.has(scope)}
|
||||||
|
disabled={disabled || mintPending}
|
||||||
|
onchange={() => toggleScope(scope)}
|
||||||
|
/>
|
||||||
|
<span class="scope-name">{scope}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<small class="scope-hint">
|
||||||
|
{mintForm.scopes.size === 0
|
||||||
|
? 'Pick at least one scope.'
|
||||||
|
: `${mintForm.scopes.size} scope${mintForm.scopes.size === 1 ? '' : 's'} selected.`}
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{#if mintError}
|
||||||
|
<div class="error">{mintError}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="ghost" onclick={cancelMint}>Cancel</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmit}>
|
||||||
|
{mintPending ? 'Minting…' : 'Mint key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refreshKeys}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if keys.length === 0 && !reveal && !mintOpen}
|
||||||
|
<p class="empty">
|
||||||
|
No API keys yet. Mint one to authenticate the CLI or external integrations.
|
||||||
|
</p>
|
||||||
|
{:else if keys.length > 0}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Name</div>
|
||||||
|
<div>Prefix</div>
|
||||||
|
<div>Scopes</div>
|
||||||
|
<div>Binding</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last used</div>
|
||||||
|
<div>Expires</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each keys as key (key.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">{key.name}</div>
|
||||||
|
<div class="mono prefix">pic_{key.prefix}…</div>
|
||||||
|
<div class="scopes-cell">
|
||||||
|
{#each key.scopes as s (s)}
|
||||||
|
<span class="scope-pill">{s}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div>{appLabel(key.app_id)}</div>
|
||||||
|
<div>{shortDate(key.created_at)}</div>
|
||||||
|
<div title={key.last_used_at ?? ''}>{relative(key.last_used_at)}</div>
|
||||||
|
<div>{key.expires_at ? shortDate(key.expires_at) : 'Never'}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger-link"
|
||||||
|
onclick={() => openRevoke(key)}
|
||||||
|
aria-label="Revoke {key.name}"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if revokeTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Revoke API key?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
busy={revokePending}
|
||||||
|
busyLabel="Revoking…"
|
||||||
|
onConfirm={confirmRevoke}
|
||||||
|
onCancel={() => (revokeTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Revoking <strong>{revokeTarget.name}</strong> (<code>{revokeTarget.prefix}</code>) takes
|
||||||
|
effect immediately. Any CLI or integration using it will start returning <code>401</code>
|
||||||
|
on the next request.
|
||||||
|
</p>
|
||||||
|
<p class="muted">This can't be undone — mint a new key if you need one again.</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.identity {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.identity-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.identity h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.identity-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||||
|
gap: 0.75rem 1.5rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.identity-meta div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
.identity-meta dt {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.identity-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reveal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.reveal h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
.reveal-sub {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.reveal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mint {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scopes {
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.scopes legend {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 0.4rem;
|
||||||
|
}
|
||||||
|
.scope-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
|
||||||
|
gap: 0.4rem 0.75rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.scope-chip.disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.scope-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.55rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.9fr 2fr 1fr 0.8fr 0.8fr 0.8fr 0.7fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
}
|
||||||
|
.prefix {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.scopes-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.scope-pill {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.danger-link {
|
||||||
|
background: transparent;
|
||||||
|
color: #fca5a5;
|
||||||
|
border: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.danger-link:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,12 +6,15 @@
|
|||||||
api,
|
api,
|
||||||
ApiError,
|
ApiError,
|
||||||
type AppDomain,
|
type AppDomain,
|
||||||
|
type AppRole,
|
||||||
type ExecutionLog,
|
type ExecutionLog,
|
||||||
type Route,
|
type Route,
|
||||||
type RouteInput,
|
type RouteInput,
|
||||||
type Script,
|
type Script,
|
||||||
type VersionInfo
|
type VersionInfo
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import { canAdminApp, canWriteApp } from '$lib/capabilities';
|
||||||
import { logLevelColor, statusColor } from '$lib/styles';
|
import { logLevelColor, statusColor } from '$lib/styles';
|
||||||
import {
|
import {
|
||||||
checkHostAgainstClaims,
|
checkHostAgainstClaims,
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
pathKindMismatchWarning
|
pathKindMismatchWarning
|
||||||
} from '$lib/route-utils';
|
} from '$lib/route-utils';
|
||||||
import CodeEditor from '$lib/CodeEditor.svelte';
|
import CodeEditor from '$lib/CodeEditor.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
import { format as formatRhai } from '$lib/rhai';
|
import { format as formatRhai } from '$lib/rhai';
|
||||||
|
|
||||||
/// Pretty-print a JSON string in place, leaving it untouched if the
|
/// Pretty-print a JSON string in place, leaving it untouched if the
|
||||||
@@ -47,6 +51,11 @@
|
|||||||
|
|
||||||
let appSlug = $state<string | null>(null);
|
let appSlug = $state<string | null>(null);
|
||||||
let appDomains = $state<AppDomain[]>([]);
|
let appDomains = $state<AppDomain[]>([]);
|
||||||
|
let appMyRole = $state<AppRole | null>(null);
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const canWrite = $derived(canWriteApp(me, appMyRole));
|
||||||
|
const canAdmin = $derived(canAdminApp(me, appMyRole));
|
||||||
|
|
||||||
async function loadScript() {
|
async function loadScript() {
|
||||||
scriptLoading = true;
|
scriptLoading = true;
|
||||||
@@ -58,15 +67,16 @@
|
|||||||
editableDescription = script.description ?? '';
|
editableDescription = script.description ?? '';
|
||||||
editableTimeout = script.timeout_seconds;
|
editableTimeout = script.timeout_seconds;
|
||||||
editableSandbox = { ...(script.sandbox ?? {}) };
|
editableSandbox = { ...(script.sandbox ?? {}) };
|
||||||
// Resolve the owning app's slug for the breadcrumb and its
|
// Resolve the owning app for the breadcrumb (slug),
|
||||||
// domain claims for the route form's suggestions + live
|
// route-form host suggestions (domain claims), and UI
|
||||||
// validation. Both are non-fatal — the page works without
|
// shadowing (my_role on this app). All non-fatal — the
|
||||||
// them.
|
// page renders without them, just with reduced fidelity.
|
||||||
const appId = script.app_id;
|
const appId = script.app_id;
|
||||||
void api.apps
|
void api.apps
|
||||||
.get(appId)
|
.get(appId)
|
||||||
.then((a) => {
|
.then((a) => {
|
||||||
appSlug = a.slug;
|
appSlug = a.slug;
|
||||||
|
appMyRole = a.my_role ?? null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
void api.domains
|
void api.domains
|
||||||
@@ -366,16 +376,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- deletion ----------------
|
// ---------------- deletion ----------------
|
||||||
|
let confirmingDelete = $state(false);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
async function remove() {
|
let deleteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
function askDelete() {
|
||||||
|
deleteError = null;
|
||||||
|
confirmingDelete = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
if (!script) return;
|
if (!script) return;
|
||||||
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
|
|
||||||
deleting = true;
|
deleting = true;
|
||||||
|
deleteError = null;
|
||||||
try {
|
try {
|
||||||
await api.scripts.remove(id);
|
await api.scripts.remove(id);
|
||||||
await goto(base + '/');
|
await goto(base + '/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : String(e));
|
deleteError = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
deleting = false;
|
deleting = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,6 +405,15 @@
|
|||||||
void loadRoutes();
|
void loadRoutes();
|
||||||
void loadLogs();
|
void loadLogs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Defense-in-depth: anyone non-admin who lands on the Settings
|
||||||
|
// tab via a stale link gets bounced back to Edit. The tab button
|
||||||
|
// itself is also hidden.
|
||||||
|
$effect(() => {
|
||||||
|
if (!canAdmin && tab === 'settings') {
|
||||||
|
tab = 'edit';
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -410,9 +438,11 @@
|
|||||||
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="danger" onclick={remove} disabled={deleting}>
|
{#if canAdmin}
|
||||||
|
<button type="button" class="danger" onclick={askDelete} disabled={deleting}>
|
||||||
{deleting ? 'Deleting…' : 'Delete'}
|
{deleting ? 'Deleting…' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
@@ -423,7 +453,9 @@
|
|||||||
<span class="badge-count">{routes.length}</span>
|
<span class="badge-count">{routes.length}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{#if canAdmin}
|
||||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
|
||||||
|
{/if}
|
||||||
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
|
||||||
Executions
|
Executions
|
||||||
</button>
|
</button>
|
||||||
@@ -435,17 +467,25 @@
|
|||||||
<section class="card">
|
<section class="card">
|
||||||
<header class="editor-header">
|
<header class="editor-header">
|
||||||
<h2>Source</h2>
|
<h2>Source</h2>
|
||||||
|
{#if canWrite}
|
||||||
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||||
Format
|
Format
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
|
<CodeEditor
|
||||||
|
bind:value={editableSource}
|
||||||
|
language="rhai"
|
||||||
|
minHeight="22rem"
|
||||||
|
readOnly={!canWrite}
|
||||||
|
/>
|
||||||
{#if rhaiFormatError}
|
{#if rhaiFormatError}
|
||||||
<div class="error inline">{rhaiFormatError}</div>
|
<div class="error inline">{rhaiFormatError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if saveSourceError}
|
{#if saveSourceError}
|
||||||
<div class="error inline">{saveSourceError}</div>
|
<div class="error inline">{saveSourceError}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if canWrite}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -455,6 +495,7 @@
|
|||||||
{savingSource ? 'Saving…' : 'Save'}
|
{savingSource ? 'Saving…' : 'Save'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -510,12 +551,14 @@
|
|||||||
<section class="card wide">
|
<section class="card wide">
|
||||||
<header class="card-header">
|
<header class="card-header">
|
||||||
<h2>Routes</h2>
|
<h2>Routes</h2>
|
||||||
|
{#if canWrite}
|
||||||
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
||||||
{showAddRoute ? 'Cancel' : '+ Add route'}
|
{showAddRoute ? 'Cancel' : '+ Add route'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showAddRoute}
|
{#if showAddRoute && canWrite}
|
||||||
<form class="route-form" onsubmit={submitRoute}>
|
<form class="route-form" onsubmit={submitRoute}>
|
||||||
<label class="full">
|
<label class="full">
|
||||||
<span>Path</span>
|
<span>Path</span>
|
||||||
@@ -626,9 +669,11 @@
|
|||||||
: r.host}
|
: r.host}
|
||||||
</span>
|
</span>
|
||||||
<span class="path">{r.path}</span>
|
<span class="path">{r.path}</span>
|
||||||
|
{#if canWrite}
|
||||||
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
||||||
remove
|
remove
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if info}
|
{#if info}
|
||||||
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
<div class="route-url muted">→ {fullUrlForRoute(r)}</div>
|
||||||
@@ -670,7 +715,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===================================================== SETTINGS ===== -->
|
<!-- ===================================================== SETTINGS ===== -->
|
||||||
{:else if tab === 'settings'}
|
{:else if tab === 'settings' && canAdmin}
|
||||||
<section class="card wide">
|
<section class="card wide">
|
||||||
<h2>General</h2>
|
<h2>General</h2>
|
||||||
<label>
|
<label>
|
||||||
@@ -786,6 +831,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmingDelete && script}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete script “{script.name}”"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete script"
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
confirmPhrase={script.name}
|
||||||
|
confirmPhrasePrompt="Type the script name to confirm:"
|
||||||
|
busy={deleting}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (confirmingDelete = false)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
This will <strong>permanently delete</strong>
|
||||||
|
<strong>{script.name}</strong>, all its routes, and all its
|
||||||
|
execution logs. There is no undo.
|
||||||
|
</p>
|
||||||
|
{#if routes.length > 0}
|
||||||
|
<p class="muted">
|
||||||
|
{routes.length} route{routes.length === 1 ? '' : 's'} bound to
|
||||||
|
this script will be removed.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if deleteError}
|
||||||
|
<p class="modal-error">{deleteError}</p>
|
||||||
|
{/if}
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
986
dashboard/src/routes/users/+page.svelte
Normal file
986
dashboard/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,986 @@
|
|||||||
|
<!--
|
||||||
|
/admin/users — owner + admin only. Members get bounced to /profile
|
||||||
|
with ?denied=users. Replaces the pre-3.5 /admin/admins page; this
|
||||||
|
one knows about roles, email, and the last-owner/last-admin guards.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type AdminDto,
|
||||||
|
type InstanceRole
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
import ActionMenu from '$lib/ActionMenu.svelte';
|
||||||
|
import { generatePassword } from '$lib/password-gen';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const myRole = $derived(me?.instance_role);
|
||||||
|
const isOwner = $derived(myRole === 'owner');
|
||||||
|
|
||||||
|
// Member guard. The backend already 403s the list call, but
|
||||||
|
// surfacing a friendly redirect avoids the dead-end empty page.
|
||||||
|
$effect(() => {
|
||||||
|
if (me && me.instance_role === 'member') {
|
||||||
|
void goto(`${base}/profile?denied=users`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let admins = $state<AdminDto[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
const filtered = $derived(
|
||||||
|
(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return admins;
|
||||||
|
return admins.filter(
|
||||||
|
(a) =>
|
||||||
|
a.username.toLowerCase().includes(q) ||
|
||||||
|
(a.email ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invite (create) modal --------------------------------------------------
|
||||||
|
let inviteOpen = $state(false);
|
||||||
|
let inviteForm = $state<{ username: string; email: string; instance_role: 'admin' | 'member' }>({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
instance_role: 'admin'
|
||||||
|
});
|
||||||
|
let invitePending = $state(false);
|
||||||
|
let inviteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// One-time password reveal (used by both invite + reset)
|
||||||
|
let revealPassword = $state<string | null>(null);
|
||||||
|
let revealForUsername = $state<string>('');
|
||||||
|
let revealKind = $state<'invite' | 'reset'>('invite');
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
// Edit modal -------------------------------------------------------------
|
||||||
|
let editTarget = $state<AdminDto | null>(null);
|
||||||
|
let editForm = $state<{
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
}>({ username: '', email: '', instance_role: 'admin' });
|
||||||
|
let editPending = $state(false);
|
||||||
|
let editError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Delete modal -----------------------------------------------------------
|
||||||
|
let deleteTarget = $state<AdminDto | null>(null);
|
||||||
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
// Deactivate modal -------------------------------------------------------
|
||||||
|
// Reactivate is one-click (non-destructive); deactivate routes
|
||||||
|
// through the modal because it signs the user out and expires
|
||||||
|
// every API key they hold.
|
||||||
|
let deactivateTarget = $state<AdminDto | null>(null);
|
||||||
|
let deactivatePending = $state(false);
|
||||||
|
|
||||||
|
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
||||||
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
const inviteUsernameValid = $derived(USERNAME_RE.test(inviteForm.username));
|
||||||
|
const inviteEmailValid = $derived(
|
||||||
|
inviteForm.email.trim() === '' || EMAIL_RE.test(inviteForm.email.trim())
|
||||||
|
);
|
||||||
|
const canInvite = $derived(inviteUsernameValid && inviteEmailValid && !invitePending);
|
||||||
|
|
||||||
|
const editUsernameValid = $derived(USERNAME_RE.test(editForm.username));
|
||||||
|
const editEmailValid = $derived(
|
||||||
|
editForm.email.trim() === '' || EMAIL_RE.test(editForm.email.trim())
|
||||||
|
);
|
||||||
|
const canSubmitEdit = $derived(editUsernameValid && editEmailValid && !editPending);
|
||||||
|
|
||||||
|
// Admin (non-owner) cannot touch owner rows for delete or role demote.
|
||||||
|
function canDelete(row: AdminDto): boolean {
|
||||||
|
if (isOwner) return true;
|
||||||
|
return row.instance_role !== 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRoleOptions = $derived<InstanceRole[]>(
|
||||||
|
isOwner ? ['owner', 'admin', 'member'] : ['admin', 'member']
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
admins = await api.admins.list();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load users';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInvite() {
|
||||||
|
inviteForm = { username: '', email: '', instance_role: 'admin' };
|
||||||
|
inviteError = null;
|
||||||
|
inviteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitInvite(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canInvite) return;
|
||||||
|
invitePending = true;
|
||||||
|
inviteError = null;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
try {
|
||||||
|
const created = await api.admins.create({
|
||||||
|
username: inviteForm.username,
|
||||||
|
password,
|
||||||
|
instance_role: inviteForm.instance_role,
|
||||||
|
email: inviteForm.email.trim() === '' ? null : inviteForm.email.trim()
|
||||||
|
});
|
||||||
|
admins = [...admins, created].sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
inviteOpen = false;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = created.username;
|
||||||
|
revealKind = 'invite';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
inviteError = e instanceof ApiError ? e.message : 'failed to create user';
|
||||||
|
} finally {
|
||||||
|
invitePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: AdminDto) {
|
||||||
|
editTarget = row;
|
||||||
|
editForm = {
|
||||||
|
username: row.username,
|
||||||
|
email: row.email ?? '',
|
||||||
|
instance_role: row.instance_role
|
||||||
|
};
|
||||||
|
editError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editTarget || !canSubmitEdit) return;
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
const patch: {
|
||||||
|
username?: string;
|
||||||
|
email?: string | null;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
} = {};
|
||||||
|
if (editForm.username !== editTarget.username) patch.username = editForm.username;
|
||||||
|
if ((editTarget.email ?? '') !== editForm.email.trim()) {
|
||||||
|
patch.email = editForm.email.trim() === '' ? null : editForm.email.trim();
|
||||||
|
}
|
||||||
|
if (editForm.instance_role !== editTarget.instance_role) {
|
||||||
|
patch.instance_role = editForm.instance_role;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(editTarget.id, patch);
|
||||||
|
admins = admins
|
||||||
|
.map((a) => (a.id === updated.id ? updated : a))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
const name = updated.username;
|
||||||
|
editTarget = null;
|
||||||
|
flash('info', `Updated "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to update user';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
if (!editTarget) return;
|
||||||
|
const target = editTarget;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
try {
|
||||||
|
await api.admins.update(target.id, { password });
|
||||||
|
editTarget = null;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = target.username;
|
||||||
|
revealKind = 'reset';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to reset password';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reactivate(row: AdminDto) {
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(row.id, { is_active: true });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
flash('info', `${updated.username} reactivated.`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDeactivate(row: AdminDto) {
|
||||||
|
deactivateTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeactivate() {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
|
deactivatePending = true;
|
||||||
|
const target = deactivateTarget;
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(target.id, { is_active: false });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
deactivateTarget = null;
|
||||||
|
flash('info', `${updated.username} deactivated.`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
|
} finally {
|
||||||
|
deactivatePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(row: AdminDto) {
|
||||||
|
deleteTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deletePending = true;
|
||||||
|
const target = deleteTarget;
|
||||||
|
try {
|
||||||
|
await api.admins.remove(target.id);
|
||||||
|
deleteTarget = null;
|
||||||
|
if (me && me.id === target.id) {
|
||||||
|
// Self-delete: bail out to login.
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admins = admins.filter((a) => a.id !== target.id);
|
||||||
|
flash('info', `Deleted "${target.username}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to delete user');
|
||||||
|
} finally {
|
||||||
|
deletePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPassword() {
|
||||||
|
if (!revealPassword) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(revealPassword);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
revealPassword = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const sec = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="head-controls">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by username or email…"
|
||||||
|
bind:value={search}
|
||||||
|
class="search"
|
||||||
|
/>
|
||||||
|
<button type="button" class="primary" onclick={openInvite}>+ Invite user</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if admins.length === 0}
|
||||||
|
<p class="empty">No users yet. Invite one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Username</div>
|
||||||
|
<div>Role</div>
|
||||||
|
<div>Email</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last login</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each filtered as row (row.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name">{row.username}</span>
|
||||||
|
{#if me && me.id === row.id}
|
||||||
|
<span class="you-tag">(you)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div><RoleChip role={row.instance_role} size="sm" /></div>
|
||||||
|
<div class="email-cell">{row.email ?? '—'}</div>
|
||||||
|
<div>
|
||||||
|
{#if row.is_active}
|
||||||
|
<span class="status status-active">● Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status status-inactive">○ Inactive</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>{shortDate(row.created_at)}</div>
|
||||||
|
<div title={row.last_login_at ?? ''}>{relative(row.last_login_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<ActionMenu
|
||||||
|
label="User actions for {row.username}"
|
||||||
|
items={[
|
||||||
|
{ label: 'Edit', onClick: () => openEdit(row) },
|
||||||
|
{
|
||||||
|
label: row.is_active ? 'Deactivate' : 'Reactivate',
|
||||||
|
onClick: () =>
|
||||||
|
row.is_active ? askDeactivate(row) : reactivate(row)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Delete',
|
||||||
|
danger: true,
|
||||||
|
disabled: !canDelete(row),
|
||||||
|
onClick: () => openDelete(row)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if filtered.length === 0 && admins.length > 0}
|
||||||
|
<div class="row empty-row">No matches for "{search}".</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Invite modal -->
|
||||||
|
{#if inviteOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !invitePending) inviteOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitInvite}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Invite user</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={invitePending}
|
||||||
|
onclick={() => (inviteOpen = false)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="modal-intro">
|
||||||
|
A random password will be generated and shown to you exactly once. PiCloud cannot send
|
||||||
|
email — copy and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>2–32 chars. Lowercase letters, digits, <code>.</code> <code>_</code> <code>-</code>.</small>
|
||||||
|
{#if inviteForm.username && !inviteUsernameValid}
|
||||||
|
<small class="invalid">Doesn't match the allowed pattern.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.email}
|
||||||
|
/>
|
||||||
|
{#if !inviteEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<fieldset class="field">
|
||||||
|
<legend>Role</legend>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="admin" />
|
||||||
|
<span>Admin — can manage users, scripts, and all apps.</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="member" />
|
||||||
|
<span>Member — only sees apps they're added to.</span>
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
Owners can't be created here — promote via Edit after creation.
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
{#if inviteError}
|
||||||
|
<div class="error">{inviteError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (inviteOpen = false)} disabled={invitePending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canInvite}>
|
||||||
|
{invitePending ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
{#if editTarget}
|
||||||
|
{@const target = editTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !editPending) editTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitEdit}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Edit {target.username}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={editPending}
|
||||||
|
onclick={() => (editTarget = null)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if editForm.username && !editUsernameValid}
|
||||||
|
<small class="invalid">2–32 chars, lowercase + digits + . _ - only.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.email}
|
||||||
|
/>
|
||||||
|
{#if !editEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Role</span>
|
||||||
|
<select bind:value={editForm.instance_role}>
|
||||||
|
{#each editRoleOptions as r (r)}
|
||||||
|
<option value={r}>{r}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>
|
||||||
|
{#if target.instance_role === 'owner' && !isOwner}
|
||||||
|
Only owners can change another owner's role.
|
||||||
|
{:else if !isOwner}
|
||||||
|
Admins can grant admin or member; only owners can grant owner.
|
||||||
|
{:else}
|
||||||
|
The last active owner can't be demoted — the request will 422 if that's the case.
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
{#if editError}
|
||||||
|
<div class="error">{editError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button type="button" class="ghost" onclick={resetPassword} disabled={editPending}>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost"
|
||||||
|
onclick={() => (editTarget = null)}
|
||||||
|
disabled={editPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmitEdit}>
|
||||||
|
{editPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password reveal (post-invite or post-reset) -->
|
||||||
|
{#if revealPassword}
|
||||||
|
<div class="modal-backdrop" role="presentation">
|
||||||
|
<div class="modal reveal-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>
|
||||||
|
{revealKind === 'invite' ? 'User created' : 'Password reset'} — {revealForUsername}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="banner banner-warn">
|
||||||
|
Save this password now — it will never be shown again. PiCloud cannot send email yet,
|
||||||
|
so copy it and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{revealPassword}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyPassword}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've shared this with the user.</span>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Deactivate confirmation -->
|
||||||
|
{#if deactivateTarget}
|
||||||
|
{@const dt = deactivateTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Deactivate {dt.username}?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Deactivate"
|
||||||
|
busyLabel="Deactivating…"
|
||||||
|
busy={deactivatePending}
|
||||||
|
onConfirm={confirmDeactivate}
|
||||||
|
onCancel={() => (deactivateTarget = null)}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
Deactivating signs <strong>{dt.username}</strong> out immediately and
|
||||||
|
expires <strong>every API key</strong> they hold. Their sessions and keys
|
||||||
|
won't come back if you reactivate — they'll need to log in again and
|
||||||
|
mint new keys.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Reactivation is one click — this isn't permanent.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
{#if deleteTarget}
|
||||||
|
{@const dt = deleteTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete user?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete user"
|
||||||
|
confirmPhrase={dt.username}
|
||||||
|
confirmPhrasePrompt="Type the username to confirm:"
|
||||||
|
busy={deletePending}
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (deleteTarget = null)}
|
||||||
|
>
|
||||||
|
{#if me && me.id === dt.id}
|
||||||
|
<p>
|
||||||
|
You're about to delete <strong>your own</strong> account. You'll be signed out
|
||||||
|
immediately and won't be able to sign back in.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This permanently removes <strong>{dt.username}</strong>, all their sessions, and all
|
||||||
|
their API keys. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="muted">
|
||||||
|
If they're the only remaining owner or active admin the server will reject the request
|
||||||
|
with a 422 — promote/activate someone else first.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.head-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
.search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
.banner-warn {
|
||||||
|
background: #2a1d04;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
color: #fde68a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.7fr 1.5fr 0.9fr 0.8fr 0.9fr 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.empty-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.you-tag {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.email-cell {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.status-active {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.status-inactive {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.reveal-modal {
|
||||||
|
border-color: #ca8a04;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.modal-intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.field legend {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='email'],
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.field small.invalid {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.field small code {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-actions.split {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
75
dashboard/tests/e2e/README.md
Normal file
75
dashboard/tests/e2e/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Dashboard E2E tests
|
||||||
|
|
||||||
|
Browser-driven tests for the PiCloud dashboard, powered by [Playwright].
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
The tests drive a real dashboard against a real backend. Bring up both
|
||||||
|
before running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Postgres
|
||||||
|
docker compose up -d postgres
|
||||||
|
|
||||||
|
# 2. Backend (port 18080 matches dashboard/vite.config.ts dev proxy)
|
||||||
|
PICLOUD_BIND=127.0.0.1:18080 \
|
||||||
|
PICLOUD_ADMIN_USERNAME=admin \
|
||||||
|
PICLOUD_ADMIN_PASSWORD=admin \
|
||||||
|
DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \
|
||||||
|
cargo run -p picloud
|
||||||
|
|
||||||
|
# 3. Browser binaries (one-time, ~200 MB)
|
||||||
|
cd dashboard && npm run test:e2e:install
|
||||||
|
```
|
||||||
|
|
||||||
|
The Vite dev server is started automatically by Playwright's `webServer`
|
||||||
|
config — you do not need to run `npm run dev` yourself.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd dashboard
|
||||||
|
npm run test:e2e # headless, full suite
|
||||||
|
npm run test:e2e:ui # interactive UI runner
|
||||||
|
npx playwright test smoke # run a single spec
|
||||||
|
npx playwright show-report
|
||||||
|
```
|
||||||
|
|
||||||
|
## Env vars
|
||||||
|
|
||||||
|
| Var | Default | Notes |
|
||||||
|
| ------------------------ | ------------------------ | ----------------------------------------------------------------- |
|
||||||
|
| `E2E_BASE_URL` | `http://localhost:5173` | Origin tests navigate against (dashboard is mounted at `/admin`). |
|
||||||
|
| `E2E_API_BASE` | `http://127.0.0.1:18080` | Backend used by globalSetup health probe + admin login. |
|
||||||
|
| `E2E_DASHBOARD_ORIGIN` | `http://localhost:5173` | Used to seed `localStorage` during globalSetup. |
|
||||||
|
| `E2E_ADMIN_USERNAME` | `admin` | Bootstrap admin to log in as. |
|
||||||
|
| `E2E_ADMIN_PASSWORD` | `admin` | Match `PICLOUD_ADMIN_PASSWORD` above. |
|
||||||
|
| `PICLOUD_DASHBOARD_PORT` | `5173` | Dev server port — picked up by both Vite and Playwright. |
|
||||||
|
|
||||||
|
## How isolation works
|
||||||
|
|
||||||
|
Tests share one backend + one Postgres. To avoid cross-test interference:
|
||||||
|
|
||||||
|
- A shared bootstrap admin session is captured once in
|
||||||
|
`tests/e2e/.auth/admin.json` (gitignored) and reused by every test via
|
||||||
|
`storageState`.
|
||||||
|
- Each test creates resources with a unique slug / username produced by
|
||||||
|
`fixtures/ids.ts` (`e2e-<prefix>-w<worker>-<random>`).
|
||||||
|
- Each test registers cleanup via `fixtures/cleanup.ts` and tears down
|
||||||
|
in `afterEach`. Cleanup is best-effort: a missing resource doesn't
|
||||||
|
fail the suite, so a test can pre-delete and still register the entry.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tests/e2e/
|
||||||
|
global-setup.ts # health probe + admin login + storageState seed
|
||||||
|
smoke.spec.ts # A.5 smoke
|
||||||
|
fixtures/
|
||||||
|
auth.ts # UI login/logout helpers (for login-flow specs)
|
||||||
|
api.ts # bearer-token-backed APIRequestContext
|
||||||
|
ids.ts # unique slug/username generators (test-fixture)
|
||||||
|
cleanup.ts # afterEach resource teardown
|
||||||
|
```
|
||||||
|
|
||||||
|
[Playwright]: https://playwright.dev
|
||||||
335
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
335
dashboard/tests/e2e/apps/apps.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor' | 'app_admin';
|
||||||
|
}): Promise<{ appId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { appId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
|
||||||
|
// historical-slug takeover flow and adversarial inputs.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
function failOnDialog(page: Page): void {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateForm(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await page.getByRole('button', { name: 'New app' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createApp(
|
||||||
|
page: Page,
|
||||||
|
opts: { name: string; slug: string; description?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(opts.name);
|
||||||
|
// Clear the auto-derived slug and type the test-controlled one so
|
||||||
|
// we know exactly which slug we'll register for cleanup.
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
await slugInput.fill(opts.slug);
|
||||||
|
if (opts.description !== undefined) {
|
||||||
|
await page.getByLabel('Description').fill(opts.description);
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B2 apps lifecycle', () => {
|
||||||
|
test('create app: slug auto-derives from name, app appears in list', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('lifecycle');
|
||||||
|
const displayName = slug.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(displayName);
|
||||||
|
// Slug auto-derives — the input value is set, no extra typing.
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await expect(slugInput).toHaveValue(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(displayName) })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit name + description in settings persists across reload', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
await createApp(page, { name: slug, slug });
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
|
||||||
|
const newName = `${slug} renamed`;
|
||||||
|
const newDesc = 'updated description';
|
||||||
|
await page.getByLabel('Name').fill(newName);
|
||||||
|
await page.getByLabel('Description').fill(newDesc);
|
||||||
|
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||||
|
// Wait for the network round-trip to settle — the busy label
|
||||||
|
// flips back to "Save changes" when done.
|
||||||
|
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await expect(page.getByLabel('Name')).toHaveValue(newName);
|
||||||
|
await expect(page.getByLabel('Description')).toHaveValue(newDesc);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete: wrong phrase keeps button disabled, right phrase removes app', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('delete');
|
||||||
|
await createApp(page, { name: slug, slug });
|
||||||
|
cleanup.app(slug); // belt-and-braces; cleanup is best-effort
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete app' }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
const phraseInput = dialog.getByRole('textbox');
|
||||||
|
const confirmBtn = dialog.getByRole('button', { name: 'Delete app' });
|
||||||
|
await expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await phraseInput.fill('wrong-phrase');
|
||||||
|
await expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
await phraseInput.fill(slug);
|
||||||
|
await expect(confirmBtn).toBeEnabled();
|
||||||
|
await confirmBtn.click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('historical slug warning surfaces; force-takeover succeeds', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const origSlug = uniqueSlug('hist');
|
||||||
|
const renamedSlug = `${origSlug}-r`;
|
||||||
|
|
||||||
|
// Historical-redirect rows are created on RENAME, not on
|
||||||
|
// delete. So: create app, rename it, original slug now lives
|
||||||
|
// in app_slug_history.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const created = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: origSlug, name: origSlug }
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const renamed = await api.patch(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(origSlug)}`,
|
||||||
|
{ data: { slug: renamedSlug } }
|
||||||
|
);
|
||||||
|
expect(renamed.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
cleanup.app(renamedSlug); // the renamed app still exists
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(origSlug);
|
||||||
|
await page.getByLabel('Slug').fill('');
|
||||||
|
await page.getByLabel('Slug').fill(origSlug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.warning')).toBeVisible();
|
||||||
|
await expect(page.locator('.warning')).toContainText(/previously redirected/i);
|
||||||
|
await page.getByRole('button', { name: /claim slug anyway/i }).click();
|
||||||
|
cleanup.app(origSlug); // the takeover created a new app
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(origSlug) })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps adversarial', () => {
|
||||||
|
test('slug with uppercase + spaces is normalized in-place', async ({ page, uniqueSlug }) => {
|
||||||
|
const base = uniqueSlug('norm');
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(base);
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
// Simulate the user typing/pasting an invalid slug. The
|
||||||
|
// oninput handler runs slugify() and rewrites the input value.
|
||||||
|
await slugInput.fill(` Hello WORLD ${base}!`);
|
||||||
|
await expect(slugInput).toHaveValue(`hello-world-${base}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss in name and description renders as text everywhere', async ({ page, uniqueSlug }) => {
|
||||||
|
failOnDialog(page);
|
||||||
|
const slug = uniqueSlug('xss');
|
||||||
|
const payload = '<img src=x onerror=alert(1)><script>window.__xss=true;</script>';
|
||||||
|
|
||||||
|
await createApp(page, { name: payload, slug, description: payload });
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
// List page — the link's accessible name contains the literal
|
||||||
|
// payload text, not the parsed HTML.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp('img src=x') })).toBeVisible();
|
||||||
|
|
||||||
|
// Detail page — open it; payload renders in the breadcrumb /
|
||||||
|
// header as text only.
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
const xssRan = await page.evaluate(
|
||||||
|
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||||
|
);
|
||||||
|
expect(xssRan).toBe(false);
|
||||||
|
expect(await page.locator('script:has-text("__xss")').count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('very long name does not crash the dashboard', async ({ page, uniqueSlug }) => {
|
||||||
|
// The backend currently has no name length cap; the dashboard
|
||||||
|
// just needs to keep rendering when handed an unusually long
|
||||||
|
// value. Guards against layout / locator regressions when a
|
||||||
|
// future test or user creates an oversized app.
|
||||||
|
const slug = uniqueSlug('long');
|
||||||
|
const longName = 'A'.repeat(10_000);
|
||||||
|
|
||||||
|
await openCreateForm(page);
|
||||||
|
await page.getByLabel('Name').fill(longName);
|
||||||
|
await page.getByLabel('Slug').fill('');
|
||||||
|
await page.getByLabel('Slug').fill(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
|
||||||
|
const errorVisible = await page
|
||||||
|
.locator('.create-form .error')
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (errorVisible) {
|
||||||
|
// Server rejected — fine, no cleanup needed.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toHaveCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server accepted — confirm the dashboard still renders and is
|
||||||
|
// navigable. Detail page must load too.
|
||||||
|
cleanup.app(slug);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B2 apps role shadowing', () => {
|
||||||
|
test('viewer member sees no "New app" on the apps list', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vlist');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
// Member can see the apps list (just the one they belong to)
|
||||||
|
// but the create-app affordance is hidden.
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vdom');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||||
|
).toBeVisible();
|
||||||
|
// Settings tab is absent.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
// Domains tab still listable, but no Add-domain submit.
|
||||||
|
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor sees New script but no Settings tab', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
118
dashboard/tests/e2e/auth/auth.spec.ts
Normal file
118
dashboard/tests/e2e/auth/auth.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { loginAsAdmin, logout } from '../fixtures/auth';
|
||||||
|
|
||||||
|
// Phase B1 — Auth & Navigation. Every interaction with the login form
|
||||||
|
// and the layout-level redirects, plus the obvious adversarial inputs.
|
||||||
|
|
||||||
|
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
function failOnDialog(page: Page): void {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(`Unexpected browser dialog fired: ${dialog.type()} — "${dialog.message()}"`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B1 auth — unauthenticated', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('valid credentials land on the apps list', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong password shows an inline error and stays on /login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel('Username').fill(VALID_USERNAME);
|
||||||
|
await page.getByLabel('Password').fill('definitely-not-the-password');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
const error = page.locator('.error');
|
||||||
|
await expect(error).toBeVisible();
|
||||||
|
await expect(error).not.toHaveText('');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
// localStorage must remain empty — a failed login should not
|
||||||
|
// leak a session token.
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
|
||||||
|
expect(token).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty submit is blocked by the browser and does not navigate', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
// HTML5 validation prevents submission; URL is unchanged and the
|
||||||
|
// username input is reported invalid.
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
const usernameInvalid = await page
|
||||||
|
.getByLabel('Username')
|
||||||
|
.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(usernameInvalid).toBe(true);
|
||||||
|
await expect(page.locator('.error')).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting an authed route redirects to /login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
await expect(page.getByLabel('Username')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password field is type=password (no plaintext echo)', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await expect(page.getByLabel('Password')).toHaveAttribute('type', 'password');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss payload in username is escaped and does not execute', async ({ page }) => {
|
||||||
|
failOnDialog(page);
|
||||||
|
const payload = '<script>window.__xss = true;</script><img src=x onerror=alert(1)>';
|
||||||
|
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel('Username').fill(payload);
|
||||||
|
await page.getByLabel('Password').fill('whatever');
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
|
// Whatever the API does with that input, the page must remain
|
||||||
|
// safe: no script tag injected into the DOM, no global side
|
||||||
|
// effect, and a visible error (since the credentials don't
|
||||||
|
// match any user).
|
||||||
|
await expect(page.locator('.error')).toBeVisible();
|
||||||
|
const xssRan = await page.evaluate(
|
||||||
|
() => (window as unknown as { __xss?: boolean }).__xss === true
|
||||||
|
);
|
||||||
|
expect(xssRan).toBe(false);
|
||||||
|
const injectedScript = await page.locator('script:has-text("__xss")').count();
|
||||||
|
expect(injectedScript).toBe(0);
|
||||||
|
// The form must still be functional after the rejected attempt.
|
||||||
|
await page.getByLabel('Username').fill('');
|
||||||
|
await page.getByLabel('Username').fill(VALID_USERNAME);
|
||||||
|
await page.getByLabel('Password').fill('');
|
||||||
|
await page.getByLabel('Password').fill(VALID_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B1 auth — authenticated', () => {
|
||||||
|
test('visiting /login while signed in bounces to /apps', async ({ page }) => {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B1 auth — logout', () => {
|
||||||
|
// Logout must NOT use the shared storageState token, or it would
|
||||||
|
// invalidate the session every other test relies on. Each run
|
||||||
|
// here logs in fresh so its session is disposable.
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('logout clears the session and lands on /login', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByRole('heading', { name: 'Apps', level: 1 })).toBeVisible();
|
||||||
|
await logout(page);
|
||||||
|
const token = await page.evaluate(() => localStorage.getItem('picloud.admin.token'));
|
||||||
|
expect(token).toBeNull();
|
||||||
|
// And the authed area is now gated again.
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
47
dashboard/tests/e2e/fixtures/api.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { request, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
const STATE_PATH = path.join(__dirname, '..', '.auth', 'admin.json');
|
||||||
|
|
||||||
|
interface StoredState {
|
||||||
|
origins: Array<{
|
||||||
|
origin: string;
|
||||||
|
localStorage: Array<{ name: string; value: string }>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedToken: string | null = null;
|
||||||
|
|
||||||
|
async function readAdminToken(): Promise<string> {
|
||||||
|
if (cachedToken) return cachedToken;
|
||||||
|
const raw = await fs.readFile(STATE_PATH, 'utf8');
|
||||||
|
const state = JSON.parse(raw) as StoredState;
|
||||||
|
for (const origin of state.origins) {
|
||||||
|
const entry = origin.localStorage.find((e) => e.name === 'picloud.admin.token');
|
||||||
|
if (entry) {
|
||||||
|
cachedToken = entry.value;
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`No picloud.admin.token in ${STATE_PATH} — did globalSetup run?`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thin wrapper around Playwright's request context that injects the
|
||||||
|
// admin bearer token from the shared storageState. Use this for
|
||||||
|
// setup/teardown shortcuts when the *test itself* is about something
|
||||||
|
// else (e.g., a script-editor test that just needs an app to exist).
|
||||||
|
export async function adminApi(): Promise<APIRequestContext> {
|
||||||
|
const token = await readAdminToken();
|
||||||
|
return request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
21
dashboard/tests/e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
// Drive the login form like a real user. globalSetup already saves a
|
||||||
|
// storageState for the shared admin, so most tests don't need this —
|
||||||
|
// it's reserved for specs that explicitly cover the login UI.
|
||||||
|
export async function loginAsAdmin(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel('Username').fill(ADMIN_USERNAME);
|
||||||
|
await page.getByLabel('Password').fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(page: Page): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: /logout/i }).click();
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
}
|
||||||
77
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
77
dashboard/tests/e2e/fixtures/cleanup.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { APIRequestContext } from '@playwright/test';
|
||||||
|
import { adminApi } from './api';
|
||||||
|
|
||||||
|
// Resources to delete after a test, in LIFO order. Tests register
|
||||||
|
// their creations and the registry tears everything down in
|
||||||
|
// `run()` — typically called from `test.afterEach`.
|
||||||
|
//
|
||||||
|
// A non-2xx status (other than 404) is treated as a real failure and
|
||||||
|
// logged to stderr. The previous shape silently swallowed every
|
||||||
|
// error, so a backend that started returning 500 on cleanup would
|
||||||
|
// have leaked orphans invisibly across runs. 404 stays tolerated —
|
||||||
|
// the test may have already deleted the resource itself.
|
||||||
|
|
||||||
|
interface CleanupItem {
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CleanupRegistry {
|
||||||
|
private items: CleanupItem[] = [];
|
||||||
|
|
||||||
|
app(slugOrId: string): void {
|
||||||
|
this.items.push({
|
||||||
|
label: `app=${slugOrId}`,
|
||||||
|
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser(userId: string): void {
|
||||||
|
this.items.push({
|
||||||
|
label: `admin=${userId}`,
|
||||||
|
path: `/api/v1/admin/admins/${userId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey(keyId: string): void {
|
||||||
|
this.items.push({
|
||||||
|
label: `key=${keyId}`,
|
||||||
|
path: `/api/v1/admin/api-keys/${keyId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(): Promise<void> {
|
||||||
|
if (this.items.length === 0) return;
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
// Copy-then-reverse so a defensive double-`run()` (or a
|
||||||
|
// caller that inspects the registry after a partial
|
||||||
|
// teardown) doesn't see the items in a re-reversed order.
|
||||||
|
for (const item of [...this.items].reverse()) {
|
||||||
|
await deleteAndReport(api, item);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAndReport(
|
||||||
|
api: APIRequestContext,
|
||||||
|
item: CleanupItem
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await api.delete(item.path);
|
||||||
|
// 2xx and 404 are both "this resource is no longer here" — fine.
|
||||||
|
if (!res.ok() && res.status() !== 404) {
|
||||||
|
console.warn(
|
||||||
|
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Network-level failure (request never reached the server,
|
||||||
|
// timeout, etc.). Log so a leak doesn't accumulate silently.
|
||||||
|
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
42
dashboard/tests/e2e/fixtures/ids.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/* eslint-disable no-empty-pattern -- Playwright fixtures require an
|
||||||
|
object-pattern first arg; these fixtures don't depend on any other
|
||||||
|
fixture so the pattern is intentionally empty. */
|
||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
// Tests share a single backend/Postgres. To avoid collisions we tag
|
||||||
|
// every resource the test creates with a short random suffix plus the
|
||||||
|
// Playwright worker index. This way two workers running the same spec
|
||||||
|
// in parallel never fight over the same slug or username.
|
||||||
|
|
||||||
|
export function shortId(): string {
|
||||||
|
return randomBytes(3).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueSlug(prefix: string, workerIndex: number): string {
|
||||||
|
const cleaned = prefix
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
return `e2e-${cleaned}-w${workerIndex}-${shortId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueUsername(prefix: string, workerIndex: number): string {
|
||||||
|
// Username regex is [a-z0-9._-]{2,32}. Mirror the slug format.
|
||||||
|
const cleaned = prefix.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
||||||
|
return `e2e${cleaned}w${workerIndex}${shortId()}`.slice(0, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<{
|
||||||
|
uniqueSlug: (prefix: string) => string;
|
||||||
|
uniqueUsername: (prefix: string) => string;
|
||||||
|
}>({
|
||||||
|
uniqueSlug: async ({}, use, testInfo) => {
|
||||||
|
await use((prefix) => uniqueSlug(prefix, testInfo.workerIndex));
|
||||||
|
},
|
||||||
|
uniqueUsername: async ({}, use, testInfo) => {
|
||||||
|
await use((prefix) => uniqueUsername(prefix, testInfo.workerIndex));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
// Helpers for tests that drive the dashboard as a non-bootstrap admin
|
||||||
|
// (member with an app-membership row, custom InstanceRole, etc.).
|
||||||
|
//
|
||||||
|
// `loginAsUserToken` exchanges username/password for a bearer token
|
||||||
|
// via the admin API. `pageWithUserToken` opens a fresh browser
|
||||||
|
// context, seeds the dashboard's localStorage entry, and returns the
|
||||||
|
// page ready to navigate. Callers are responsible for closing the
|
||||||
|
// returned page's context.
|
||||||
|
|
||||||
|
import { expect, request, type Browser, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
export async function loginAsUserToken(
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const probe = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await probe.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username, password },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { token: string }).token;
|
||||||
|
} finally {
|
||||||
|
await probe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pageWithUserToken(
|
||||||
|
browser: Browser,
|
||||||
|
token: string
|
||||||
|
): Promise<Page> {
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
// Seed localStorage on the right origin, then navigate normally.
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
146
dashboard/tests/e2e/global-setup.ts
Normal file
146
dashboard/tests/e2e/global-setup.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { chromium, request } from '@playwright/test';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
const DASHBOARD_PORT = Number(process.env.PICLOUD_DASHBOARD_PORT ?? 5173);
|
||||||
|
const DASHBOARD_ORIGIN = process.env.E2E_DASHBOARD_ORIGIN ?? `http://localhost:${DASHBOARD_PORT}`;
|
||||||
|
const ADMIN_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const ADMIN_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
const AUTH_DIR = path.join(__dirname, '.auth');
|
||||||
|
const ADMIN_STATE_PATH = path.join(AUTH_DIR, 'admin.json');
|
||||||
|
|
||||||
|
export default async function globalSetup(): Promise<void> {
|
||||||
|
await assertBackendUp();
|
||||||
|
await fs.mkdir(AUTH_DIR, { recursive: true });
|
||||||
|
const token = await loginAsAdmin();
|
||||||
|
await sweepOrphans(token);
|
||||||
|
await persistAdminStorageState(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertBackendUp(): Promise<void> {
|
||||||
|
const probe = await request.newContext();
|
||||||
|
try {
|
||||||
|
const res = await probe.get(`${API_BASE}/healthz`, { timeout: 5_000 });
|
||||||
|
if (!res.ok()) {
|
||||||
|
throw new Error(
|
||||||
|
`backend /healthz returned ${res.status()} — is \`cargo run -p picloud\` listening on ${API_BASE}?`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not reach backend at ${API_BASE}/healthz. ` +
|
||||||
|
`Bring it up before running E2E tests:\n\n` +
|
||||||
|
` docker compose up -d postgres\n` +
|
||||||
|
` PICLOUD_BIND=127.0.0.1:18080 \\\n` +
|
||||||
|
` PICLOUD_ADMIN_USERNAME=${ADMIN_USERNAME} \\\n` +
|
||||||
|
` PICLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} \\\n` +
|
||||||
|
` DATABASE_URL=postgres://picloud:picloud@127.0.0.1:15432/picloud \\\n` +
|
||||||
|
` cargo run -p picloud\n\n` +
|
||||||
|
`Underlying error: ${(err as Error).message}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await probe.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginAsAdmin(): Promise<string> {
|
||||||
|
const ctx = await request.newContext();
|
||||||
|
try {
|
||||||
|
const res = await ctx.post(`${API_BASE}/api/v1/admin/auth/login`, {
|
||||||
|
data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
if (!res.ok()) {
|
||||||
|
const body = await res.text();
|
||||||
|
throw new Error(
|
||||||
|
`Admin login failed (${res.status()}): ${body}. ` +
|
||||||
|
`Verify PICLOUD_ADMIN_USERNAME / PICLOUD_ADMIN_PASSWORD match the seeded bootstrap admin.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { token?: string };
|
||||||
|
if (!payload.token) {
|
||||||
|
throw new Error('Admin login response missing token field');
|
||||||
|
}
|
||||||
|
return payload.token;
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up apps + admin users left over from a previous crashed run.
|
||||||
|
// The convention is that every e2e-created resource has a slug
|
||||||
|
// starting with `e2e-` (apps) or a username starting with `e2e`
|
||||||
|
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
|
||||||
|
// not stop the suite from running.
|
||||||
|
async function sweepOrphans(token: string): Promise<void> {
|
||||||
|
const ctx = await request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const res = await ctx.get('/api/v1/admin/apps');
|
||||||
|
if (res.ok()) {
|
||||||
|
const apps = (await res.json()) as Array<{ slug: string }>;
|
||||||
|
for (const app of apps) {
|
||||||
|
if (!app.slug.startsWith('e2e-')) continue;
|
||||||
|
try {
|
||||||
|
await ctx.delete(
|
||||||
|
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Individual delete failure is non-fatal — the per-test
|
||||||
|
// cleanup will catch it on the next run.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Listing failed; nothing to do but proceed.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await ctx.get('/api/v1/admin/admins');
|
||||||
|
if (res.ok()) {
|
||||||
|
const admins = (await res.json()) as Array<{ id: string; username: string }>;
|
||||||
|
for (const a of admins) {
|
||||||
|
if (!/^e2e/.test(a.username)) continue;
|
||||||
|
try {
|
||||||
|
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
|
||||||
|
} catch {
|
||||||
|
// Same per-row tolerance as above.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Listing failed; same as above.
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashboard reads its session from localStorage under the key
|
||||||
|
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
|
||||||
|
// localStorage without a browser context, so launch a throwaway one,
|
||||||
|
// seed the value, then save storageState for every test to reuse.
|
||||||
|
async function persistAdminStorageState(token: string): Promise<void> {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(`${DASHBOARD_ORIGIN}/admin/login`);
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
await context.storageState({ path: ADMIN_STATE_PATH });
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
158
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
158
dashboard/tests/e2e/integration/integration.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { expect, request, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Full-stack integration scenarios. Unlike the per-page B1–B8 specs,
|
||||||
|
// these drive a complete user journey across multiple pages and then
|
||||||
|
// verify the data plane / API surface behaves the way the dashboard
|
||||||
|
// promised it would.
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||||||
|
const cm = page.locator(locator).first();
|
||||||
|
await cm.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('end-to-end: app + domain + script + route via dashboard → invoke via public URL', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('public');
|
||||||
|
const domain = `${slug}.local`;
|
||||||
|
const routePath = `/${slug}/hello`;
|
||||||
|
const scriptName = `${slug}-hello`;
|
||||||
|
const scriptSource = `return #{ statusCode: 200, body: #{ source: "public", slug: "${slug}" } };`;
|
||||||
|
|
||||||
|
// 1. Create the app from the apps list.
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await page.getByRole('button', { name: 'New app' }).click();
|
||||||
|
await page.getByLabel('Name').fill(slug);
|
||||||
|
const slugInput = page.getByLabel('Slug');
|
||||||
|
await slugInput.fill('');
|
||||||
|
await slugInput.fill(slug);
|
||||||
|
await page.getByRole('button', { name: 'Create app' }).click();
|
||||||
|
cleanup.app(slug);
|
||||||
|
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
|
||||||
|
|
||||||
|
// 2. Open the app and claim the domain on the Domains tab.
|
||||||
|
await page.getByRole('link', { name: new RegExp(slug) }).click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/admin/apps/${slug}$`));
|
||||||
|
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
|
||||||
|
const domainForm = page.locator('form.create-form.inline');
|
||||||
|
await domainForm.getByPlaceholder(/app\.example\.com/).fill(domain);
|
||||||
|
await domainForm.getByRole('button', { name: /^Add domain$/ }).click();
|
||||||
|
await expect(page.locator('.domain-row')).toContainText(domain);
|
||||||
|
|
||||||
|
// 3. Create the script on the Scripts tab.
|
||||||
|
await page.getByRole('button', { name: /^Scripts \(\d+\)$/ }).click();
|
||||||
|
await page.getByRole('button', { name: /^New script$/ }).click();
|
||||||
|
await page.getByLabel('Name').fill(scriptName);
|
||||||
|
await fillCodeMirror(page, '.cm-content', scriptSource);
|
||||||
|
await page.getByRole('button', { name: /^Create script$/ }).click();
|
||||||
|
|
||||||
|
// 4. Open the script and bind a route on the Routing tab.
|
||||||
|
await page.getByRole('link', { name: new RegExp(scriptName) }).click();
|
||||||
|
await page.getByRole('button', { name: 'Routing' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
const routeForm = page.locator('form.route-form');
|
||||||
|
await routeForm.getByLabel('Path', { exact: true }).fill(routePath);
|
||||||
|
await routeForm.getByLabel('Method').selectOption('GET');
|
||||||
|
await routeForm.getByLabel(/^Host/).fill(domain);
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-list')).toContainText(routePath);
|
||||||
|
|
||||||
|
// 5. Invoke via the public URL, with the Host header pointing at
|
||||||
|
// the claimed domain. The dev backend listens on 127.0.0.1; the
|
||||||
|
// orchestrator resolves the app from Host, then the route.
|
||||||
|
const publicCtx = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await publicCtx.get(routePath, { headers: { host: domain } });
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = (await res.json()) as { source: string; slug: string };
|
||||||
|
expect(body.source).toBe('public');
|
||||||
|
expect(body.slug).toBe(slug);
|
||||||
|
} finally {
|
||||||
|
await publicCtx.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
// Worker-aware unique helper instead of Date.now() — keeps two
|
||||||
|
// workers from minting the same name on the same millisecond.
|
||||||
|
const name = uniqueUsername('cli');
|
||||||
|
|
||||||
|
// 1. Mint the key from /profile and capture the revealed token.
|
||||||
|
await page.goto('/admin/profile');
|
||||||
|
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||||||
|
const mintForm = page.locator('form.mint');
|
||||||
|
await mintForm.getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||||
|
// script:read is enough to read the scripts list — that's our
|
||||||
|
// "CLI verb" below.
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||||
|
|
||||||
|
const reveal = page.locator('.reveal');
|
||||||
|
await expect(reveal).toBeVisible();
|
||||||
|
const rawToken = (await reveal.locator('code.token').textContent())?.trim();
|
||||||
|
expect(rawToken).toBeTruthy();
|
||||||
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
|
||||||
|
// 2. Act like a CLI: call the API directly with Bearer <token>.
|
||||||
|
const cli = await request.newContext({
|
||||||
|
baseURL: API_BASE,
|
||||||
|
extraHTTPHeaders: { authorization: `Bearer ${rawToken}` }
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const ok = await cli.get('/api/v1/admin/scripts');
|
||||||
|
expect(ok.status()).toBe(200);
|
||||||
|
const body = (await ok.json()) as unknown;
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
|
||||||
|
// Sanity: a route the scope doesn't cover must reject.
|
||||||
|
// `script:read` cannot list instance admins (that's
|
||||||
|
// instance:admin territory).
|
||||||
|
const denied = await cli.get('/api/v1/admin/admins');
|
||||||
|
expect(denied.status()).toBe(403);
|
||||||
|
|
||||||
|
// 3. Revoke via the dashboard.
|
||||||
|
await page.reload();
|
||||||
|
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||||||
|
await expect(revokeBtn).toBeVisible();
|
||||||
|
await revokeBtn.click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: /^Revoke$/ }).click();
|
||||||
|
await expect(revokeBtn).toHaveCount(0);
|
||||||
|
|
||||||
|
// 4. Same CLI call must now fail auth.
|
||||||
|
const afterRevoke = await cli.get('/api/v1/admin/scripts');
|
||||||
|
expect(afterRevoke.status()).toBe(401);
|
||||||
|
} finally {
|
||||||
|
await cli.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Belt-and-braces cleanup: if the UI revoke missed, drop via API.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const list = await api.get('/api/v1/admin/api-keys');
|
||||||
|
if (list.ok()) {
|
||||||
|
const all = (await list.json()) as Array<{ id: string; name: string }>;
|
||||||
|
const k = all.find((x) => x.name === name);
|
||||||
|
if (k) cleanup.apiKey(k.id);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
168
dashboard/tests/e2e/members/members.spec.ts
Normal file
168
dashboard/tests/e2e/members/members.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { expect } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
// Phase B5 — App Members. Setup creates one or two extra admin
|
||||||
|
// users via the API; tests drive the Members tab through the
|
||||||
|
// dashboard like a real app admin would.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createApp(slug: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMemberUser(username: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username, password: 'e2e-member-pw', instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B5 app members', () => {
|
||||||
|
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('inv');
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||||
|
|
||||||
|
// Invite. Both selects sit in `form.create-form`; locate them
|
||||||
|
// by position to avoid getByLabel ambiguity (the Svelte
|
||||||
|
// markup nests both labels in a flex row, which makes their
|
||||||
|
// accessible names overlap).
|
||||||
|
const form = page.locator('form.create-form');
|
||||||
|
await form.locator('select').nth(0).selectOption({ label: username });
|
||||||
|
await form.locator('select').nth(1).selectOption('editor');
|
||||||
|
await page.getByRole('button', { name: /^Add member$/ }).click();
|
||||||
|
await expect(page.locator('.member-row')).toContainText(username);
|
||||||
|
|
||||||
|
// Remove via action menu + confirm modal.
|
||||||
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Remove from app$/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: /^Remove member$/ }).click();
|
||||||
|
await expect(page.locator('.member-row')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('role change via action menu updates the role chip', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('role');
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
// Seed the membership via API to skip the invite UI.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
||||||
|
data: { user_id: userId, role: 'viewer' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||||
|
await page.getByRole('button', { name: new RegExp(`Member actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Make editor$/ }).click();
|
||||||
|
|
||||||
|
const row = page.locator('.member-row', { hasText: username });
|
||||||
|
await expect(row).toContainText(/editor/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-app-admin viewers do not see the Members tab', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const password = 'e2e-member-pw';
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
// Grant viewer membership (not app_admin) so the user can see
|
||||||
|
// the app at all.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post(`/api/v1/admin/apps/${slug}/members`, {
|
||||||
|
data: { user_id: userId, role: 'viewer' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, password);
|
||||||
|
const viewerPage = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await viewerPage.goto(`/admin/apps/${slug}`);
|
||||||
|
// Scripts tab loads — that's what a viewer sees.
|
||||||
|
await expect(
|
||||||
|
viewerPage.getByRole('button', { name: /^Scripts \(\d+\)$/ })
|
||||||
|
).toBeVisible();
|
||||||
|
// Members tab button is absent for non-app-admins.
|
||||||
|
await expect(
|
||||||
|
viewerPage.getByRole('button', { name: /^Members \(\d+\)$/ })
|
||||||
|
).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await viewerPage.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B5 app members adversarial', () => {
|
||||||
|
test('role dropdown exposes only the documented values', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mem');
|
||||||
|
const username = uniqueUsername('rolelist');
|
||||||
|
await createApp(slug);
|
||||||
|
const userId = await createMemberUser(username);
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^Members \(\d+\)$/ }).click();
|
||||||
|
const form = page.locator('form.create-form');
|
||||||
|
const roleSelect = form.locator('select').nth(1);
|
||||||
|
const optionValues = await roleSelect.evaluate((el: HTMLSelectElement) =>
|
||||||
|
Array.from(el.options).map((o) => o.value)
|
||||||
|
);
|
||||||
|
expect(optionValues.sort()).toEqual(['app_admin', 'editor', 'viewer']);
|
||||||
|
});
|
||||||
|
});
|
||||||
150
dashboard/tests/e2e/profile/profile.spec.ts
Normal file
150
dashboard/tests/e2e/profile/profile.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B7 — Profile + API Keys (/admin/profile). Covers the
|
||||||
|
// mint/reveal/revoke flow, the app-binding mutual-exclusion guard,
|
||||||
|
// and adversarial inputs.
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createApp(slug: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', { data: { slug, name: slug } });
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openMintForm(page: Page): Promise<void> {
|
||||||
|
await page.goto('/admin/profile');
|
||||||
|
await page.getByRole('button', { name: /\+ Mint API key/ }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerKeyCleanupByName(name: string): Promise<void> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.get('/api/v1/admin/api-keys');
|
||||||
|
const all = (await res.json()) as Array<{ id: string; name: string }>;
|
||||||
|
const k = all.find((x) => x.name === name);
|
||||||
|
if (k) cleanup.apiKey(k.id);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B7 profile + API keys', () => {
|
||||||
|
test('mint instance-wide key: reveal → ack → key appears in list', async ({ page }) => {
|
||||||
|
const name = `e2e-mint-${Date.now()}`;
|
||||||
|
await openMintForm(page);
|
||||||
|
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||||
|
// Pick a non-instance scope so we don't need to worry about
|
||||||
|
// mutual exclusion here. The scope-chip is a <label> wrapping
|
||||||
|
// the checkbox — clicking the label toggles it.
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||||
|
|
||||||
|
const reveal = page.locator('.reveal');
|
||||||
|
await expect(reveal).toBeVisible();
|
||||||
|
await expect(reveal.locator('code.token')).toContainText(/\S{16,}/);
|
||||||
|
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
||||||
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
|
||||||
|
await registerKeyCleanupByName(name);
|
||||||
|
await expect(page.getByText(name)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binding to an app disables instance scopes', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('keyapp');
|
||||||
|
const appId = await createApp(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await openMintForm(page);
|
||||||
|
|
||||||
|
// Default binding is Instance-wide — instance scopes are
|
||||||
|
// enabled.
|
||||||
|
const instChip = page.locator('label.scope-chip', { hasText: 'instance:admin' });
|
||||||
|
await expect(instChip).not.toHaveClass(/disabled/);
|
||||||
|
|
||||||
|
// Switch binding to the app. The chip becomes disabled.
|
||||||
|
await page.getByLabel(/Binding/i).selectOption(appId);
|
||||||
|
await expect(instChip).toHaveClass(/disabled/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke key removes it from the list', async ({ page }) => {
|
||||||
|
const name = `e2e-revoke-${Date.now()}`;
|
||||||
|
// Seed a key via API so the test focuses on the revoke UI.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/api-keys', {
|
||||||
|
data: { name, scopes: ['script:read'] }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
cleanup.apiKey(body.id);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto('/admin/profile');
|
||||||
|
const revokeBtn = page.getByRole('button', { name: `Revoke ${name}` });
|
||||||
|
await expect(revokeBtn).toBeVisible();
|
||||||
|
await revokeBtn.click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByRole('button', { name: /^Revoke$/ }).click();
|
||||||
|
// Assert the row's revoke button is gone (the flash banner
|
||||||
|
// also mentions the name, so a plain getByText would still
|
||||||
|
// match — anchor on the row-scoped button instead).
|
||||||
|
await expect(revokeBtn).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('denied=users banner shows when arriving from the users redirect', async ({ page }) => {
|
||||||
|
await page.goto('/admin/profile?denied=users');
|
||||||
|
await expect(page.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B7 profile adversarial', () => {
|
||||||
|
test('empty name keeps the mint button disabled', async ({ page }) => {
|
||||||
|
await openMintForm(page);
|
||||||
|
// Trying to click would HTML5-validate; instead verify the
|
||||||
|
// button is disabled while name is empty.
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /^Mint key$/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('copy-token button copies the full token, not a truncated form', async ({
|
||||||
|
page,
|
||||||
|
context
|
||||||
|
}) => {
|
||||||
|
// Permission must be granted explicitly; chromium will throw
|
||||||
|
// otherwise when calling navigator.clipboard.readText().
|
||||||
|
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
|
|
||||||
|
const name = `e2e-copy-${Date.now()}`;
|
||||||
|
await openMintForm(page);
|
||||||
|
await page.locator('form.mint').getByPlaceholder('e.g. ci-deploy').fill(name);
|
||||||
|
await page.locator('label.scope-chip', { hasText: 'script:read' }).click();
|
||||||
|
await page.getByRole('button', { name: /^Mint key$/ }).click();
|
||||||
|
|
||||||
|
const reveal = page.locator('.reveal');
|
||||||
|
const tokenInDom = await reveal.locator('code.token').textContent();
|
||||||
|
expect(tokenInDom).toBeTruthy();
|
||||||
|
await reveal.getByRole('button', { name: /^Copy$/ }).click();
|
||||||
|
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
expect(copied).toBe(tokenInDom);
|
||||||
|
|
||||||
|
await reveal.getByRole('checkbox', { name: /saved this token/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
await registerKeyCleanupByName(name);
|
||||||
|
});
|
||||||
|
});
|
||||||
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
189
dashboard/tests/e2e/routing/routing.spec.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B4 — Routing tab in the script editor. Add / remove / match
|
||||||
|
// preview + validation paths (host check, path-kind mismatch, reserved
|
||||||
|
// prefix, duplicate conflict, adversarial paths).
|
||||||
|
|
||||||
|
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function makeAppWithScript(slug: string): Promise<{ appId: string; scriptId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug, name: slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appBody = (await appRes.json()) as { id: string };
|
||||||
|
|
||||||
|
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appBody.id, name: 'route-target', source: HELLO_RHAI }
|
||||||
|
});
|
||||||
|
expect(scriptRes.ok()).toBe(true);
|
||||||
|
const scriptBody = (await scriptRes.json()) as { id: string };
|
||||||
|
|
||||||
|
return { appId: appBody.id, scriptId: scriptBody.id };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gotoRoutingTab(page: Page, scriptId: string): Promise<void> {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await page.getByRole('button', { name: 'Routing' }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRoute(
|
||||||
|
page: Page,
|
||||||
|
opts: { path: string; pathKind?: 'exact' | 'param' | 'prefix'; method?: string; host?: string }
|
||||||
|
): Promise<void> {
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
const form = page.locator('form.route-form');
|
||||||
|
await form.getByLabel('Path', { exact: true }).fill(opts.path);
|
||||||
|
if (opts.pathKind) {
|
||||||
|
await form.getByLabel('Path kind').selectOption(opts.pathKind);
|
||||||
|
}
|
||||||
|
if (opts.method !== undefined) {
|
||||||
|
await form.getByLabel('Method').selectOption(opts.method);
|
||||||
|
}
|
||||||
|
if (opts.host !== undefined) {
|
||||||
|
await form.getByLabel(/^Host/).fill(opts.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B4 routing', () => {
|
||||||
|
test('add route appears in list and matches in the preview', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('addr');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/greet', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
|
||||||
|
await expect(page.locator('.route-list')).toContainText('/greet');
|
||||||
|
|
||||||
|
// Match preview confirms the route resolves.
|
||||||
|
await page.getByLabel('URL').fill('http://localhost/greet');
|
||||||
|
await page.locator('.actions').getByRole('button', { name: 'Match' }).click();
|
||||||
|
await expect(page.locator('pre.preview')).toContainText('script_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove route updates the list', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('remr');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/transient', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-list')).toContainText('/transient');
|
||||||
|
|
||||||
|
// removeRoute() uses window.confirm — accept it.
|
||||||
|
page.once('dialog', (d) => void d.accept());
|
||||||
|
await page.locator('.route-list').getByRole('button', { name: 'remove' }).click();
|
||||||
|
await expect(page.locator('.route-list')).toHaveCount(0);
|
||||||
|
await expect(page.getByText(/no routes yet/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplicate route surfaces a 409 conflict error inline', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('dupr');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/twice', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-list')).toContainText('/twice');
|
||||||
|
|
||||||
|
// Same path + method again — must conflict.
|
||||||
|
await addRoute(page, { path: '/twice', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-form .error.inline')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('path-kind mismatch warns inline when /:name is set to exact', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('mism');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
await page.getByLabel('Path', { exact: true }).fill('/users/:id');
|
||||||
|
// Override to a wrong kind — auto-detect would have picked
|
||||||
|
// `param`; selecting `exact` should fire the warning.
|
||||||
|
await page.getByLabel('Path kind').selectOption('exact');
|
||||||
|
await expect(page.locator('.route-form .warning.inline')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host validation warns when the host is not a claimed domain', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('unclaim');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await page.getByRole('button', { name: '+ Add route' }).click();
|
||||||
|
await page.getByLabel('Path', { exact: true }).fill('/x');
|
||||||
|
await page.getByLabel(/^Host/).fill('example.test-not-claimed.local');
|
||||||
|
// One of the inline warnings is the unclaimed-host explainer.
|
||||||
|
await expect(page.locator('.route-form .warning.inline').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B4 routing adversarial', () => {
|
||||||
|
test('reserved prefix /api/ is rejected with a visible error', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('reserv');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, { path: '/api/v9/oops', method: 'GET' });
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
await expect(page.locator('.route-form .error.inline')).toBeVisible();
|
||||||
|
await expect(page.locator('.route-form .error.inline')).toContainText(
|
||||||
|
/reserved|api|prefix/i
|
||||||
|
);
|
||||||
|
// Empty-state copy renders when no routes exist; the path
|
||||||
|
// itself must not appear anywhere on the routing tab.
|
||||||
|
await expect(page.getByText(/no routes yet/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss payload in path stored or rejected — never executes on render', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
page.on('dialog', async (d) => {
|
||||||
|
await d.dismiss();
|
||||||
|
throw new Error(`Unexpected dialog: ${d.message()}`);
|
||||||
|
});
|
||||||
|
const slug = uniqueSlug('pxss');
|
||||||
|
const { scriptId } = await makeAppWithScript(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await gotoRoutingTab(page, scriptId);
|
||||||
|
await addRoute(page, {
|
||||||
|
path: '/<script>alert(1)</script>',
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /^Create route$/ }).click();
|
||||||
|
|
||||||
|
// Either accepted (rendered as text in the list) or rejected
|
||||||
|
// (error inline). Both fine — what's NOT fine is an alert
|
||||||
|
// dialog or an injected <script> tag in the list.
|
||||||
|
const xssScripts = await page.locator('.route-list script:has-text("alert")').count();
|
||||||
|
expect(xssScripts).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
337
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
337
dashboard/tests/e2e/scripts/scripts.spec.ts
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { expect, type Page } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
|
||||||
|
|
||||||
|
const MEMBER_PW = 'e2e-member-pw';
|
||||||
|
|
||||||
|
async function seedAppScriptAndMember(opts: {
|
||||||
|
slug: string;
|
||||||
|
username: string;
|
||||||
|
role: 'viewer' | 'editor';
|
||||||
|
}): Promise<{ scriptId: string; userId: string }> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const appRes = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug: opts.slug, name: opts.slug }
|
||||||
|
});
|
||||||
|
expect(appRes.ok()).toBe(true);
|
||||||
|
const appId = ((await appRes.json()) as { id: string }).id;
|
||||||
|
const scriptRes = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
|
||||||
|
});
|
||||||
|
expect(scriptRes.ok()).toBe(true);
|
||||||
|
const scriptId = ((await scriptRes.json()) as { id: string }).id;
|
||||||
|
const userRes = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(userRes.ok()).toBe(true);
|
||||||
|
const userId = ((await userRes.json()) as { id: string }).id;
|
||||||
|
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
|
||||||
|
data: { user_id: userId, role: opts.role }
|
||||||
|
});
|
||||||
|
expect(memberRes.ok()).toBe(true);
|
||||||
|
return { scriptId, userId };
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
|
||||||
|
// /admin/scripts/{id}. Setup uses the API to create the app (and
|
||||||
|
// sometimes a baseline script) so each test can focus on the editor
|
||||||
|
// flow it actually covers.
|
||||||
|
|
||||||
|
const HELLO_RHAI = `return #{ statusCode: 200, body: #{ ok: true } };`;
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createAppViaApi(slug: string): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/apps', {
|
||||||
|
data: { slug, name: slug }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
return body.id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createScriptViaApi(
|
||||||
|
appId: string,
|
||||||
|
name: string,
|
||||||
|
source = HELLO_RHAI
|
||||||
|
): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/scripts', {
|
||||||
|
data: { app_id: appId, name, source }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const body = (await res.json()) as { id: string };
|
||||||
|
return body.id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillCodeMirror(page: Page, locator: string, text: string): Promise<void> {
|
||||||
|
const cm = page.locator(locator).first();
|
||||||
|
await cm.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B3 scripts CRUD', () => {
|
||||||
|
test('create script via UI navigates to scripts list with the new entry', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('cscr');
|
||||||
|
await createAppViaApi(slug);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/apps/${slug}`);
|
||||||
|
await page.getByRole('button', { name: /^New script$/ }).click();
|
||||||
|
await page.getByLabel('Name').fill('echo');
|
||||||
|
// The CodeMirror editor starts empty in create mode; type a
|
||||||
|
// minimal valid script.
|
||||||
|
await fillCodeMirror(page, '.cm-content', HELLO_RHAI);
|
||||||
|
await page.getByRole('button', { name: 'Create script' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('link', { name: /echo/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('edit + save Rhai source persists across reload', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('edit');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'edit-target');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await expect(page.locator('.cm-content').first()).toContainText('statusCode');
|
||||||
|
|
||||||
|
const updated = `// edited by e2e\nreturn #{ statusCode: 201, body: #{ edited: true } };`;
|
||||||
|
await fillCodeMirror(page, '.cm-content', updated);
|
||||||
|
await page.getByRole('button', { name: /^Save$/ }).click();
|
||||||
|
// Save button becomes disabled once the buffer matches the
|
||||||
|
// just-saved source — that's our settle signal.
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.cm-content').first()).toContainText('edited by e2e');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invalid Rhai source: Format shows a parse error', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('invrhai');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'bad-syntax');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await fillCodeMirror(page, '.cm-content', 'this is not rhai @@@ {{{');
|
||||||
|
await page
|
||||||
|
.locator('.editor-header')
|
||||||
|
.getByRole('button', { name: 'Format' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.error.inline').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 test-invoke', () => {
|
||||||
|
test('valid JSON body returns status + body in the result panel', async ({
|
||||||
|
page,
|
||||||
|
uniqueSlug
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('inv-ok');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'invoke-ok');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Body editor is the second .cm-content (source is first).
|
||||||
|
const bodyEditor = page.locator('.cm-content').nth(1);
|
||||||
|
await bodyEditor.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type('{"hello":"world"}');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /^Send$/ }).click();
|
||||||
|
await expect(page.locator('.status')).toContainText('HTTP 200');
|
||||||
|
await expect(page.locator('.result pre')).toContainText('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('malformed JSON body: Format surfaces the parse error', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('inv-bad');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'invoke-bad');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
const bodyEditor = page.locator('.cm-content').nth(1);
|
||||||
|
await bodyEditor.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+A');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.keyboard.type('{not valid json,');
|
||||||
|
|
||||||
|
// The Format button for the request body sits inside the
|
||||||
|
// Test-invoke card next to the body editor.
|
||||||
|
await page
|
||||||
|
.locator('.json-block')
|
||||||
|
.first()
|
||||||
|
.getByRole('button', { name: 'Format' })
|
||||||
|
.click();
|
||||||
|
await expect(page.locator('.error.inline').first()).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 settings', () => {
|
||||||
|
test('timeout input rejects zero and non-positive values', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('settz');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(appId, 'settings-target');
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
const timeout = page.getByLabel(/Timeout/);
|
||||||
|
await timeout.fill('0');
|
||||||
|
const invalid = await timeout.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||||
|
expect(invalid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 scripts role shadowing', () => {
|
||||||
|
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vscr');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Header Delete is hidden for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Save/Format on the Edit tab are hidden for viewers.
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.locator('.editor-header').getByRole('button', { name: 'Format' })
|
||||||
|
).toHaveCount(0);
|
||||||
|
// Test invoke is still visible (everyone with read access).
|
||||||
|
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
|
||||||
|
// Routing tab loads, no +Add route.
|
||||||
|
await page.getByRole('button', { name: /Routing/ }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
|
||||||
|
// Settings tab is absent for non-admins.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewer: CodeMirror is read-only', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('vro');
|
||||||
|
const username = uniqueUsername('viewer');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'viewer'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
const cm = page.locator('.cm-content').first();
|
||||||
|
await expect(cm).toBeVisible();
|
||||||
|
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
|
||||||
|
// is in effect; that's the canonical signal for read-only mode.
|
||||||
|
await expect(cm).toHaveAttribute('contenteditable', 'false');
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editor: Save visible, Delete header hidden', async ({
|
||||||
|
browser,
|
||||||
|
uniqueSlug,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const slug = uniqueSlug('escr');
|
||||||
|
const username = uniqueUsername('editor');
|
||||||
|
const { scriptId, userId } = await seedAppScriptAndMember({
|
||||||
|
slug,
|
||||||
|
username,
|
||||||
|
role: 'editor'
|
||||||
|
});
|
||||||
|
cleanup.app(slug);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginAsUserToken(username, MEMBER_PW);
|
||||||
|
const page = await pageWithUserToken(browser, token);
|
||||||
|
try {
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
// Editor sees Save (disabled until the buffer changes — that's fine).
|
||||||
|
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
|
||||||
|
// Delete stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
|
||||||
|
// Settings stays admin-only.
|
||||||
|
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
await page.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B3 adversarial', () => {
|
||||||
|
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
|
||||||
|
const slug = uniqueSlug('loop');
|
||||||
|
const appId = await createAppViaApi(slug);
|
||||||
|
const scriptId = await createScriptViaApi(
|
||||||
|
appId,
|
||||||
|
'inf-loop',
|
||||||
|
'loop { let x = 1; }'
|
||||||
|
);
|
||||||
|
cleanup.app(slug);
|
||||||
|
|
||||||
|
await page.goto(`/admin/scripts/${scriptId}`);
|
||||||
|
await page.getByRole('button', { name: /^Send$/ }).click();
|
||||||
|
|
||||||
|
// Either the status renders with a 5xx code, or an error
|
||||||
|
// banner shows up. Either way, the page recovers.
|
||||||
|
await Promise.race([
|
||||||
|
expect(page.locator('.status')).toBeVisible({ timeout: 30_000 }),
|
||||||
|
expect(page.locator('.error.inline').last()).toBeVisible({ timeout: 30_000 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The dashboard must remain interactive after the timeout.
|
||||||
|
await page.getByRole('button', { name: 'Settings' }).click();
|
||||||
|
await expect(page.getByLabel(/Timeout/)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
81
dashboard/tests/e2e/security/security.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
// Phase B8 — Cross-cutting security. Things that aren't tied to a
|
||||||
|
// single page: session handling, secret leakage, error states for
|
||||||
|
// missing resources, and a sanity check that no XSS sink fires
|
||||||
|
// anywhere in the dashboard's main authed routes.
|
||||||
|
|
||||||
|
const VALID_USERNAME = process.env.E2E_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const VALID_PASSWORD = process.env.E2E_ADMIN_PASSWORD ?? 'admin';
|
||||||
|
|
||||||
|
test.describe('B8 cross-cutting security', () => {
|
||||||
|
test('expired/stale token: any authed call redirects to /login', async ({ page }) => {
|
||||||
|
// Replace the storageState token with an obvious garbage
|
||||||
|
// value; the fetch wrapper treats 401 as "go to /login".
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('picloud.admin.token', 'expired-or-bogus-token');
|
||||||
|
});
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('login response cookie is HttpOnly', async ({ request }) => {
|
||||||
|
const res = await request.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username: VALID_USERNAME, password: VALID_PASSWORD },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
const headers = res.headers();
|
||||||
|
const setCookie = headers['set-cookie'];
|
||||||
|
// Backend may or may not set a cookie (the dashboard primarily
|
||||||
|
// uses bearer-in-localStorage). If it does, it must be
|
||||||
|
// HttpOnly so XSS can't exfiltrate it.
|
||||||
|
if (setCookie) {
|
||||||
|
expect(setCookie.toLowerCase()).toContain('httponly');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bootstrap password is not present in the DOM after login', async ({ page }) => {
|
||||||
|
await page.goto('/admin/apps');
|
||||||
|
const body = await page.locator('body').innerText();
|
||||||
|
expect(body).not.toContain(VALID_PASSWORD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-existent app slug shows a recoverable error, not a crash', async ({ page }) => {
|
||||||
|
await page.goto('/admin/apps/does-not-exist-e2e-9999');
|
||||||
|
// Page must render *something* and the layout must remain
|
||||||
|
// intact (header link to Apps still works).
|
||||||
|
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
|
||||||
|
// And surface the failure to the user — either a "couldn't
|
||||||
|
// load" message or a "back to apps" link.
|
||||||
|
const errorOrBack = page.locator('.error, a[href$="/admin/apps"]');
|
||||||
|
await expect(errorOrBack.first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('xss probe across major surfaces never fires a dialog', async ({ page }) => {
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
await dialog.dismiss();
|
||||||
|
throw new Error(
|
||||||
|
`XSS sink fired — got a ${dialog.type()} dialog: "${dialog.message()}"`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cover each main authed route. None should evaluate any
|
||||||
|
// payload that earlier tests may have stored, and none should
|
||||||
|
// inject inline <script> tags from server responses.
|
||||||
|
for (const path of ['/admin/apps', '/admin/profile', '/admin/users']) {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
const inlineScripts = await page.locator('script[src=""], script:not([src])').count();
|
||||||
|
// Svelte itself injects no inline <script> in the
|
||||||
|
// production bundle; vite dev does, but never with
|
||||||
|
// onerror/alert payload text in them.
|
||||||
|
const evilInline = await page
|
||||||
|
.locator('script:has-text("alert"), script:has-text("__xss")')
|
||||||
|
.count();
|
||||||
|
expect(evilInline, `evil inline script tag on ${path}`).toBe(0);
|
||||||
|
expect(inlineScripts).toBeGreaterThanOrEqual(0); // sanity assertion, no crash
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
28
dashboard/tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { loginAsAdmin } from './fixtures/auth';
|
||||||
|
|
||||||
|
// A1 smoke: prove globalSetup + webServer + fixtures + proxy all work.
|
||||||
|
|
||||||
|
test.describe('smoke', () => {
|
||||||
|
test.describe('unauthenticated', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('root redirects to login and shows the form', async ({ page }) => {
|
||||||
|
await page.goto('/admin/');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/login$/);
|
||||||
|
await expect(page.getByLabel('Username')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Password')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid credentials land on the apps page', async ({ page }) => {
|
||||||
|
await loginAsAdmin(page);
|
||||||
|
await expect(page.getByRole('link', { name: 'Apps' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin storageState already lands on apps', async ({ page }) => {
|
||||||
|
await page.goto('/admin/');
|
||||||
|
await expect(page).toHaveURL(/\/admin\/apps$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
224
dashboard/tests/e2e/users/users.spec.ts
Normal file
224
dashboard/tests/e2e/users/users.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { expect, type Browser, type Page, request } from '@playwright/test';
|
||||||
|
import { test } from '../fixtures/ids';
|
||||||
|
import { CleanupRegistry } from '../fixtures/cleanup';
|
||||||
|
import { adminApi } from '../fixtures/api';
|
||||||
|
|
||||||
|
// Phase B6 — Instance Users (/admin/users). Covers the bootstrap
|
||||||
|
// admin's view of the user directory: invite, edit, deactivate,
|
||||||
|
// search, delete, plus the member-role redirect and adversarial
|
||||||
|
// inputs to the invite form.
|
||||||
|
|
||||||
|
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
|
||||||
|
|
||||||
|
const cleanup = new CleanupRegistry();
|
||||||
|
test.afterEach(async () => {
|
||||||
|
await cleanup.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createMember(username: string, password = 'e2e-member-pw'): Promise<string> {
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const res = await api.post('/api/v1/admin/admins', {
|
||||||
|
data: { username, password, instance_role: 'member' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { id: string }).id;
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginToken(username: string, password: string): Promise<string> {
|
||||||
|
const ctx = await request.newContext({ baseURL: API_BASE });
|
||||||
|
try {
|
||||||
|
const res = await ctx.post('/api/v1/admin/auth/login', {
|
||||||
|
data: { username, password },
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(res.ok()).toBe(true);
|
||||||
|
return ((await res.json()) as { token: string }).token;
|
||||||
|
} finally {
|
||||||
|
await ctx.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pageWithToken(browser: Browser, token: string): Promise<Page> {
|
||||||
|
const ctx = await browser.newContext({ storageState: undefined });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.evaluate(
|
||||||
|
([key, value]) => {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
},
|
||||||
|
['picloud.admin.token', token]
|
||||||
|
);
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('B6 instance users', () => {
|
||||||
|
test('invite happy path: form → reveal modal → user in list', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('inv');
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel('Username').fill(username);
|
||||||
|
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||||
|
await modal.getByRole('button', { name: /^Create user$/ }).click();
|
||||||
|
|
||||||
|
// Reveal modal shows the one-time password.
|
||||||
|
const reveal = page.locator('.reveal-modal');
|
||||||
|
await expect(reveal).toBeVisible();
|
||||||
|
await expect(reveal).toContainText(/User created — /);
|
||||||
|
await expect(reveal.getByRole('button', { name: /^Done$/ })).toBeDisabled();
|
||||||
|
await reveal.getByRole('checkbox', { name: /shared this/i }).check();
|
||||||
|
await reveal.getByRole('button', { name: /^Done$/ }).click();
|
||||||
|
|
||||||
|
// Now in the table.
|
||||||
|
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toBeVisible();
|
||||||
|
|
||||||
|
// API cleanup — we don't have the user id from the UI alone.
|
||||||
|
const api = await adminApi();
|
||||||
|
try {
|
||||||
|
const list = await api.get('/api/v1/admin/admins');
|
||||||
|
const all = (await list.json()) as Array<{ id: string; username: string }>;
|
||||||
|
const u = all.find((x) => x.username === username);
|
||||||
|
if (u) cleanup.adminUser(u.id);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('username live validation: bad chars → submit disabled', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel('Username').fill('UPPER_CASE_invalid');
|
||||||
|
await expect(modal.locator('small.invalid')).toContainText(/allowed pattern/i);
|
||||||
|
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||||
|
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search filters the table by username', async ({ page, uniqueUsername }) => {
|
||||||
|
const target = uniqueUsername('hit');
|
||||||
|
const decoy = uniqueUsername('miss');
|
||||||
|
const ids = await Promise.all([createMember(target), createMember(decoy)]);
|
||||||
|
ids.forEach((id) => cleanup.adminUser(id));
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByPlaceholder(/Search by username/).fill(target);
|
||||||
|
await expect(page.locator('.row', { hasText: target })).toBeVisible();
|
||||||
|
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('toggle');
|
||||||
|
const userId = await createMember(username);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByPlaceholder(/Search by username/).fill(username);
|
||||||
|
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||||
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
|
// Deactivate opens the confirm modal.
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog).toContainText(username);
|
||||||
|
|
||||||
|
// Cancel leaves the user active.
|
||||||
|
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
|
||||||
|
await expect(dialog).toHaveCount(0);
|
||||||
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Open again and confirm — user becomes inactive.
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
|
||||||
|
await expect(row).toContainText(/inactive/i);
|
||||||
|
|
||||||
|
// Reactivate is still one-click (non-destructive — no modal).
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
|
||||||
|
await expect(row).not.toContainText(/inactive/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('delete: wrong phrase keeps disabled, right phrase removes the user', async ({
|
||||||
|
page,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('del');
|
||||||
|
const userId = await createMember(username);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByPlaceholder(/Search by username/).fill(username);
|
||||||
|
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
|
||||||
|
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
|
||||||
|
await page.getByRole('menuitem', { name: /^Delete$/ }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
const confirm = dialog.getByRole('button', { name: /^Delete user$/ });
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
await dialog.getByRole('textbox').fill('not-the-username');
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
await dialog.getByRole('textbox').fill(username);
|
||||||
|
await expect(confirm).toBeEnabled();
|
||||||
|
await confirm.click();
|
||||||
|
|
||||||
|
await expect(page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('member-role user visiting /admin/users is bounced to profile with denied banner', async ({
|
||||||
|
browser,
|
||||||
|
uniqueUsername
|
||||||
|
}) => {
|
||||||
|
const username = uniqueUsername('memvw');
|
||||||
|
const password = 'e2e-member-pw';
|
||||||
|
const userId = await createMember(username, password);
|
||||||
|
cleanup.adminUser(userId);
|
||||||
|
|
||||||
|
const token = await loginToken(username, password);
|
||||||
|
const memberPage = await pageWithToken(browser, token);
|
||||||
|
try {
|
||||||
|
await memberPage.goto('/admin/users');
|
||||||
|
await expect(memberPage).toHaveURL(/\/admin\/profile\?denied=users$/);
|
||||||
|
await expect(memberPage.getByText(/don.?t have access to the Users page/i)).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await memberPage.context().close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('B6 instance users adversarial', () => {
|
||||||
|
test('username too short: live invalid + submit disabled', async ({ page }) => {
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel('Username').fill('a'); // 1 char — minimum is 2
|
||||||
|
await expect(modal.locator('small.invalid')).toBeVisible();
|
||||||
|
await modal.getByRole('radio', { name: /^Member/ }).check();
|
||||||
|
await expect(modal.getByRole('button', { name: /^Create user$/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email with script tag fails validation, never executes', async ({ page }) => {
|
||||||
|
page.on('dialog', async (d) => {
|
||||||
|
await d.dismiss();
|
||||||
|
throw new Error(`Unexpected dialog: ${d.message()}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/admin/users');
|
||||||
|
await page.getByRole('button', { name: '+ Invite user' }).click();
|
||||||
|
const modal = page.locator('form.modal');
|
||||||
|
await modal.getByLabel(/Email/).fill('<script>alert(1)</script>@x');
|
||||||
|
await expect(modal.locator('small.invalid')).toContainText(/email/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['src/lib/rhai/**/*.test.ts'],
|
include: ['src/lib/**/*.test.ts'],
|
||||||
environment: 'node'
|
environment: 'node'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ services:
|
|||||||
|
|
||||||
caddy:
|
caddy:
|
||||||
image: caddy:2-alpine
|
image: caddy:2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${PICLOUD_HOST_PORT:-8000}:80"
|
- "${PICLOUD_HOST_PORT:-8000}:80"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1049,7 +1049,7 @@ pub struct Principal {
|
|||||||
| Role | Powers |
|
| Role | Powers |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
|
||||||
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
|
| `admin` | create apps, invite users, implicit `app_admin` on every app. Cannot manage instance-wide settings (sandbox ceiling, etc.) or other owners. |
|
||||||
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
|
||||||
|
|
||||||
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
|
||||||
@@ -1058,11 +1058,13 @@ The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'`
|
|||||||
|
|
||||||
| Role | Grants |
|
| Role | Grants |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app_admin` | settings, domain claims, delete app |
|
| `app_admin` | settings, domain claims, delete app, **delete scripts** |
|
||||||
| `editor` | CRUD on scripts, routes, sandbox config |
|
| `editor` | create + edit scripts, routes, sandbox config (no script delete) |
|
||||||
| `viewer` | read scripts + execution logs |
|
| `viewer` | read scripts + execution logs |
|
||||||
|
|
||||||
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
|
Implicit grants from instance role: every `owner` and every `admin` is `app_admin` on every app — a single-human install would otherwise have to add itself to each new app's `app_members`. Explicit `app_members` rows are the only path for `member` users.
|
||||||
|
|
||||||
|
Script **save** uses `AppWriteScript` (editor+); script **delete** uses `AppAdmin` (app_admin+). Editors can iterate on a script's source freely but cannot remove it — destructive cleanup stays with the role that also owns the app.
|
||||||
|
|
||||||
### Auth Methods — Same Principal, Different Extractor
|
### Auth Methods — Same Principal, Different Extractor
|
||||||
|
|
||||||
@@ -1156,6 +1158,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 +1195,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