Compare commits
10 Commits
feat/users
...
feat/users
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eb32a78bf | ||
|
|
fc35d59236 | ||
|
|
0c9f11558a | ||
|
|
39a6df2bfe | ||
|
|
d21cbdb164 | ||
|
|
700ae7b7d1 | ||
|
|
f16ff22a5a | ||
|
|
bd2258499e | ||
|
|
df691038d7 | ||
|
|
3688c26cb4 |
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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>
|
||||||
45
dashboard/src/lib/RoleChip.svelte
Normal file
45
dashboard/src/lib/RoleChip.svelte
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { InstanceRole } from '$lib/auth';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: InstanceRole;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
let { role, size = 'md' }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="chip chip-{role}" class:sm={size === 'sm'}>{role}</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;
|
||||||
|
}
|
||||||
|
</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;
|
||||||
@@ -232,10 +234,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 +277,42 @@ 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 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 +323,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 +342,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 +362,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`),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
25
dashboard/src/lib/password-gen.ts
Normal file
25
dashboard/src/lib/password-gen.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// 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).
|
||||||
|
|
||||||
|
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
|
||||||
|
|
||||||
|
export function generatePassword(length = 16): string {
|
||||||
|
if (length < 8) {
|
||||||
|
throw new Error('password length must be at least 8');
|
||||||
|
}
|
||||||
|
const buf = new Uint32Array(length);
|
||||||
|
crypto.getRandomValues(buf);
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
out += CHARSET[buf[i] % CHARSET.length];
|
||||||
|
}
|
||||||
|
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">
|
||||||
<span class="username">{user.username}</span>
|
<a href={base + '/profile'} class="profile-chip" title="View profile">
|
||||||
|
<RoleChip role={user.instance_role} size="sm" />
|
||||||
|
<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>
|
|
||||||
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>
|
||||||
937
dashboard/src/routes/users/+page.svelte
Normal file
937
dashboard/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,937 @@
|
|||||||
|
<!--
|
||||||
|
/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);
|
||||||
|
|
||||||
|
// 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 toggleActive(row: AdminDto) {
|
||||||
|
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 user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: () => toggleActive(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}
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
Reference in New Issue
Block a user