mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add desktop API token management (#39)
This commit is contained in:
@@ -33,6 +33,7 @@ export default defineConfig({
|
||||
"**/stream.spec.ts",
|
||||
"**/integration.spec.ts",
|
||||
"**/profile.spec.ts",
|
||||
"**/tokens.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
Generated
+2
@@ -4017,10 +4017,12 @@ dependencies = [
|
||||
name = "sprout"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"nostr 0.37.0",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sprout-core",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
|
||||
@@ -27,3 +27,5 @@ serde_json = "1"
|
||||
nostr = "0.37"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
sprout-core = { path = "../../crates/sprout-core" }
|
||||
base64 = "0.22"
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
||||
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32};
|
||||
use reqwest::Method;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use serde::{de::DeserializeOwned, Deserialize, Deserializer, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sprout_core::PresenceStatus;
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
pub struct AppState {
|
||||
pub keys: Mutex<Keys>,
|
||||
pub http_client: reqwest::Client,
|
||||
pub configured_api_token: Option<String>,
|
||||
pub session_token: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -50,6 +54,7 @@ pub struct ChannelInfo {
|
||||
pub name: String,
|
||||
pub channel_type: String,
|
||||
pub visibility: String,
|
||||
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
|
||||
pub description: String,
|
||||
pub topic: Option<String>,
|
||||
pub purpose: Option<String>,
|
||||
@@ -66,6 +71,7 @@ pub struct ChannelDetailInfo {
|
||||
pub name: String,
|
||||
pub channel_type: String,
|
||||
pub visibility: String,
|
||||
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
|
||||
pub description: String,
|
||||
pub topic: Option<String>,
|
||||
pub topic_set_by: Option<String>,
|
||||
@@ -170,6 +176,49 @@ struct SearchQueryParams<'a> {
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct MintTokenBody<'a> {
|
||||
name: &'a str,
|
||||
scopes: &'a [String],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
channel_ids: Option<&'a [String]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
expires_in_days: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MintTokenResponse {
|
||||
pub id: String,
|
||||
pub token: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub channel_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TokenInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<String>,
|
||||
pub channel_ids: Vec<String>,
|
||||
pub created_at: String,
|
||||
pub expires_at: Option<String>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ListTokensResponse {
|
||||
pub tokens: Vec<TokenInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct RevokeAllTokensResponse {
|
||||
pub revoked_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct FeedItemInfo {
|
||||
pub id: String,
|
||||
@@ -222,6 +271,13 @@ pub struct SearchResponse {
|
||||
pub found: u64,
|
||||
}
|
||||
|
||||
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn relay_ws_url() -> String {
|
||||
std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
@@ -242,10 +298,15 @@ fn build_authed_request(
|
||||
path: &str,
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
let pubkey_hex = auth_pubkey_header(state)?;
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
let request = client.request(method, url);
|
||||
|
||||
Ok(client.request(method, url).header("X-Pubkey", pubkey_hex))
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
return Ok(request.header("Authorization", format!("Bearer {token}")));
|
||||
}
|
||||
|
||||
let pubkey_hex = auth_pubkey_header(state)?;
|
||||
Ok(request.header("X-Pubkey", pubkey_hex))
|
||||
}
|
||||
|
||||
fn auth_pubkey_header(state: &AppState) -> Result<String, String> {
|
||||
@@ -253,14 +314,68 @@ fn auth_pubkey_header(state: &AppState) -> Result<String, String> {
|
||||
Ok(keys.public_key().to_hex())
|
||||
}
|
||||
|
||||
fn session_api_token(state: &AppState) -> Result<Option<String>, String> {
|
||||
let token = state.session_token.lock().map_err(|e| e.to_string())?;
|
||||
Ok(token.clone())
|
||||
}
|
||||
|
||||
fn build_token_management_request(
|
||||
client: &reqwest::Client,
|
||||
method: Method,
|
||||
path: &str,
|
||||
state: &AppState,
|
||||
) -> Result<reqwest::RequestBuilder, String> {
|
||||
let url = format!("{}{}", relay_api_base_url(), path);
|
||||
let request = client.request(method, url);
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
return Ok(request.header("Authorization", format!("Bearer {token}")));
|
||||
}
|
||||
|
||||
if let Some(token) = session_api_token(state)? {
|
||||
return Ok(request.header("Authorization", format!("Bearer {token}")));
|
||||
}
|
||||
|
||||
let pubkey_hex = auth_pubkey_header(state)?;
|
||||
Ok(request.header("X-Pubkey", pubkey_hex))
|
||||
}
|
||||
|
||||
fn build_nip98_auth_header(
|
||||
method: &Method,
|
||||
url: &str,
|
||||
body: &[u8],
|
||||
state: &AppState,
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
let payload_hash = format!("{:x}", Sha256::digest(body));
|
||||
let tags = vec![
|
||||
Tag::parse(vec!["u", url]).map_err(|e| format!("url tag failed: {e}"))?,
|
||||
Tag::parse(vec!["method", method.as_str()])
|
||||
.map_err(|e| format!("method tag failed: {e}"))?,
|
||||
Tag::parse(vec!["payload", &payload_hash])
|
||||
.map_err(|e| format!("payload tag failed: {e}"))?,
|
||||
];
|
||||
|
||||
let event = EventBuilder::new(Kind::HttpAuth, "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.map_err(|e| format!("sign failed: {e}"))?;
|
||||
|
||||
Ok(format!("Nostr {}", BASE64.encode(event.as_json().as_bytes())))
|
||||
}
|
||||
|
||||
async fn relay_error_message(response: reqwest::Response) -> String {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
if let Some(message) = value.get("error").and_then(serde_json::Value::as_str) {
|
||||
if let Some(message) = value.get("message").and_then(serde_json::Value::as_str) {
|
||||
return format!("relay returned {status}: {message}");
|
||||
}
|
||||
|
||||
if let Some(error) = value.get("error").and_then(serde_json::Value::as_str) {
|
||||
return format!("relay returned {status}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
format!("relay returned {status}: {body}")
|
||||
@@ -454,12 +569,19 @@ fn create_auth_event(
|
||||
) -> Result<String, String> {
|
||||
let keys = state.keys.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
let tags = vec![
|
||||
let mut tags = vec![
|
||||
Tag::parse(vec!["relay", &relay_url]).map_err(|e| format!("relay tag failed: {e}"))?,
|
||||
Tag::parse(vec!["challenge", &challenge])
|
||||
.map_err(|e| format!("challenge tag failed: {e}"))?,
|
||||
];
|
||||
|
||||
if let Some(token) = state.configured_api_token.as_deref() {
|
||||
tags.push(
|
||||
Tag::parse(vec!["auth_token", token])
|
||||
.map_err(|e| format!("auth token tag failed: {e}"))?,
|
||||
);
|
||||
}
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(22242), "")
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
@@ -684,6 +806,78 @@ async fn get_event(event_id: String, state: tauri::State<'_, AppState>) -> Resul
|
||||
response.text().await.map_err(|e| format!("parse failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_tokens(state: tauri::State<'_, AppState>) -> Result<ListTokensResponse, String> {
|
||||
let request =
|
||||
build_token_management_request(&state.http_client, Method::GET, "/api/tokens", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn mint_token(
|
||||
name: String,
|
||||
scopes: Vec<String>,
|
||||
channel_ids: Option<Vec<String>>,
|
||||
expires_in_days: Option<u32>,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<MintTokenResponse, String> {
|
||||
let body = MintTokenBody {
|
||||
name: &name,
|
||||
scopes: &scopes,
|
||||
channel_ids: channel_ids.as_deref(),
|
||||
expires_in_days,
|
||||
};
|
||||
let request = if state.configured_api_token.is_some() {
|
||||
build_authed_request(&state.http_client, Method::POST, "/api/tokens", &state)?.json(&body)
|
||||
} else {
|
||||
let url = format!("{}{}", relay_api_base_url(), "/api/tokens");
|
||||
let body_bytes =
|
||||
serde_json::to_vec(&body).map_err(|e| format!("serialize failed: {e}"))?;
|
||||
let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, &state)?;
|
||||
let forwarded_proto = if url.starts_with("http://") {
|
||||
"http"
|
||||
} else {
|
||||
"https"
|
||||
};
|
||||
|
||||
state
|
||||
.http_client
|
||||
.request(Method::POST, url)
|
||||
.header("Authorization", auth_header)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("X-Forwarded-Proto", forwarded_proto)
|
||||
.body(body_bytes)
|
||||
};
|
||||
let response: MintTokenResponse = send_json_request(request).await?;
|
||||
|
||||
if state.configured_api_token.is_none() {
|
||||
let mut token = state.session_token.lock().map_err(|e| e.to_string())?;
|
||||
*token = Some(response.token.clone());
|
||||
}
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn revoke_token(
|
||||
token_id: String,
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let path = format!("/api/tokens/{token_id}");
|
||||
let request =
|
||||
build_token_management_request(&state.http_client, Method::DELETE, &path, &state)?;
|
||||
send_empty_request(request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn revoke_all_tokens(
|
||||
state: tauri::State<'_, AppState>,
|
||||
) -> Result<RevokeAllTokensResponse, String> {
|
||||
let request =
|
||||
build_token_management_request(&state.http_client, Method::DELETE, "/api/tokens", &state)?;
|
||||
send_json_request(request).await
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// GUI app: warn on bad key but don't crash — fall back to ephemeral.
|
||||
@@ -708,9 +902,20 @@ pub fn run() {
|
||||
keys.public_key().to_hex()
|
||||
);
|
||||
|
||||
let api_token = match std::env::var("SPROUT_API_TOKEN") {
|
||||
Ok(token) if !token.trim().is_empty() => Some(token),
|
||||
Ok(_) | Err(std::env::VarError::NotPresent) => None,
|
||||
Err(std::env::VarError::NotUnicode(_)) => {
|
||||
eprintln!("sprout-desktop: SPROUT_API_TOKEN contains invalid UTF-8");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let app_state = AppState {
|
||||
keys: Mutex::new(keys),
|
||||
http_client: reqwest::Client::new(),
|
||||
configured_api_token: api_token,
|
||||
session_token: Mutex::new(None),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
@@ -752,6 +957,10 @@ pub fn run() {
|
||||
get_feed,
|
||||
search_messages,
|
||||
get_event,
|
||||
list_tokens,
|
||||
mint_token,
|
||||
revoke_token,
|
||||
revoke_all_tokens,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useProfileQuery,
|
||||
useUpdateProfileMutation,
|
||||
} from "@/features/profile/hooks";
|
||||
import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
PresenceBadge,
|
||||
@@ -542,6 +543,7 @@ export function SettingsView({
|
||||
currentPubkey={currentPubkey}
|
||||
fallbackDisplayName={fallbackDisplayName}
|
||||
/>
|
||||
<TokenSettingsCard currentPubkey={currentPubkey} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
listTokens,
|
||||
mintToken,
|
||||
revokeAllTokens,
|
||||
revokeToken,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { MintTokenInput, Token } from "@/shared/api/types";
|
||||
|
||||
export const tokensQueryKey = ["tokens"] as const;
|
||||
|
||||
export function useTokensQuery() {
|
||||
return useQuery({
|
||||
queryKey: tokensQueryKey,
|
||||
queryFn: listTokens,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMintTokenMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: MintTokenInput) => mintToken(input),
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: tokensQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeTokenMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (tokenId: string) => revokeToken(tokenId),
|
||||
onMutate: async (tokenId) => {
|
||||
await queryClient.cancelQueries({ queryKey: tokensQueryKey });
|
||||
const previous = queryClient.getQueryData<Token[]>(tokensQueryKey);
|
||||
|
||||
queryClient.setQueryData<Token[]>(tokensQueryKey, (old) =>
|
||||
old?.map((t) =>
|
||||
t.id === tokenId ? { ...t, revokedAt: new Date().toISOString() } : t,
|
||||
),
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _tokenId, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(tokensQueryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: tokensQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeAllTokensMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => revokeAllTokens(),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: tokensQueryKey });
|
||||
const previous = queryClient.getQueryData<Token[]>(tokensQueryKey);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
queryClient.setQueryData<Token[]>(tokensQueryKey, (old) =>
|
||||
old?.map((t) => (t.revokedAt ? t : { ...t, revokedAt: now })),
|
||||
);
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, context) => {
|
||||
if (context?.previous) {
|
||||
queryClient.setQueryData(tokensQueryKey, context.previous);
|
||||
}
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: tokensQueryKey });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,785 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
useMintTokenMutation,
|
||||
useRevokeAllTokensMutation,
|
||||
useRevokeTokenMutation,
|
||||
useTokensQuery,
|
||||
} from "@/features/tokens/hooks";
|
||||
import { getChannelMembers } from "@/shared/api/tauri";
|
||||
import type { Channel, Token, TokenScope } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
|
||||
const ALL_SCOPES: { value: TokenScope; label: string }[] = [
|
||||
{ value: "messages:read", label: "Messages: Read" },
|
||||
{ value: "messages:write", label: "Messages: Write" },
|
||||
{ value: "channels:read", label: "Channels: Read" },
|
||||
{ value: "channels:write", label: "Channels: Write" },
|
||||
{ value: "users:read", label: "Users: Read" },
|
||||
{ value: "files:read", label: "Files: Read" },
|
||||
{ value: "files:write", label: "Files: Write" },
|
||||
];
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: 7, label: "7 days" },
|
||||
{ value: 30, label: "30 days" },
|
||||
{ value: 90, label: "90 days" },
|
||||
{ value: 365, label: "1 year" },
|
||||
{ value: 0, label: "No expiry" },
|
||||
] as const;
|
||||
|
||||
const MAX_ACTIVE_TOKENS = 10;
|
||||
|
||||
function tokenStatus(token: Token): "active" | "revoked" | "expired" {
|
||||
if (token.revokedAt) return "revoked";
|
||||
if (token.expiresAt && new Date(token.expiresAt) < new Date())
|
||||
return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: "active" | "revoked" | "expired" }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
status === "active" &&
|
||||
"bg-green-500/10 text-green-700 dark:text-green-400",
|
||||
status === "revoked" && "bg-muted text-muted-foreground",
|
||||
status === "expired" &&
|
||||
"bg-yellow-500/10 text-yellow-700 dark:text-yellow-400",
|
||||
)}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScopeBadge({ scope }: { scope: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
{scope}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60_000);
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
if (diffDays < 30) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function channelLabel(channelId: string, channelsById: Map<string, Channel>) {
|
||||
return (
|
||||
channelsById.get(channelId)?.name ?? `Channel ${channelId.slice(0, 8)}`
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({
|
||||
channelsById,
|
||||
token,
|
||||
onRevoke,
|
||||
isRevoking,
|
||||
}: {
|
||||
channelsById: Map<string, Channel>;
|
||||
token: Token;
|
||||
onRevoke: (id: string) => void;
|
||||
isRevoking: boolean;
|
||||
}) {
|
||||
const status = tokenStatus(token);
|
||||
const visibleChannelIds = token.channelIds.slice(0, 4);
|
||||
const hiddenChannelCount = token.channelIds.length - visibleChannelIds.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start justify-between gap-3 rounded-lg border border-border/60 bg-background/60 px-3 py-2.5"
|
||||
data-testid={`token-row-${token.id}`}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{token.name}</span>
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{token.scopes.map((scope) => (
|
||||
<ScopeBadge key={scope} scope={scope} />
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Created {formatRelativeDate(token.createdAt)}
|
||||
{token.lastUsedAt
|
||||
? ` · Last used ${formatRelativeDate(token.lastUsedAt)}`
|
||||
: " · Never used"}
|
||||
{token.expiresAt ? ` · Expires ${formatDate(token.expiresAt)}` : ""}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{token.channelIds.length === 0
|
||||
? "All accessible channels"
|
||||
: `Scoped to ${token.channelIds.length} channel${token.channelIds.length === 1 ? "" : "s"}`}
|
||||
</p>
|
||||
{visibleChannelIds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{visibleChannelIds.map((channelId) => (
|
||||
<ScopeBadge
|
||||
key={channelId}
|
||||
scope={channelLabel(channelId, channelsById)}
|
||||
/>
|
||||
))}
|
||||
{hiddenChannelCount > 0 ? (
|
||||
<ScopeBadge scope={`+${hiddenChannelCount} more`} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{status === "active" ? (
|
||||
<Button
|
||||
data-testid={`revoke-token-${token.id}`}
|
||||
disabled={isRevoking}
|
||||
onClick={() => onRevoke(token.id)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">Revoke</span>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTokenDialog({
|
||||
activeTokenCount,
|
||||
currentPubkey,
|
||||
channels,
|
||||
hiddenChannelsCount,
|
||||
channelsError,
|
||||
isLoadingChannels,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
activeTokenCount: number;
|
||||
currentPubkey?: string;
|
||||
channels: Channel[];
|
||||
hiddenChannelsCount: number;
|
||||
channelsError: Error | null;
|
||||
isLoadingChannels: boolean;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const mintMutation = useMintTokenMutation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [selectedScopes, setSelectedScopes] = React.useState<Set<TokenScope>>(
|
||||
new Set(),
|
||||
);
|
||||
const [channelAccessMode, setChannelAccessMode] = React.useState<
|
||||
"all" | "selected"
|
||||
>("all");
|
||||
const [selectedChannelIds, setSelectedChannelIds] = React.useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [expiryDays, setExpiryDays] = React.useState<number>(30);
|
||||
const [mintedToken, setMintedToken] = React.useState<string | null>(null);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const canCreate =
|
||||
activeTokenCount < MAX_ACTIVE_TOKENS &&
|
||||
name.trim().length > 0 &&
|
||||
name.trim().length <= 100 &&
|
||||
selectedScopes.size > 0 &&
|
||||
(channelAccessMode === "all" || selectedChannelIds.size > 0) &&
|
||||
!mintMutation.isPending;
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setSelectedScopes(new Set());
|
||||
setChannelAccessMode("all");
|
||||
setSelectedChannelIds(new Set());
|
||||
setExpiryDays(30);
|
||||
setMintedToken(null);
|
||||
setCopied(false);
|
||||
mintMutation.reset();
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
reset();
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
function toggleScope(scope: TokenScope) {
|
||||
setSelectedScopes((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(scope)) {
|
||||
next.delete(scope);
|
||||
} else {
|
||||
next.add(scope);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleChannel(channelId: string) {
|
||||
setSelectedChannelIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(channelId)) {
|
||||
next.delete(channelId);
|
||||
} else {
|
||||
next.add(channelId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const result = await mintMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
scopes: [...selectedScopes],
|
||||
channelIds:
|
||||
channelAccessMode === "selected" ? [...selectedChannelIds] : undefined,
|
||||
expiresInDays: expiryDays === 0 ? undefined : expiryDays,
|
||||
});
|
||||
setMintedToken(result.token);
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!mintedToken) return;
|
||||
await navigator.clipboard.writeText(mintedToken);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (mintedToken) {
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-lg overflow-hidden p-0"
|
||||
data-testid="token-created-dialog"
|
||||
>
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Token created</DialogTitle>
|
||||
<DialogDescription>
|
||||
Copy this token now. You will not be able to see it again.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||
{mintedToken}
|
||||
</code>
|
||||
<Button onClick={handleCopy} size="sm" variant="outline">
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
This is the only time this token will be shown. Store it
|
||||
securely.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end border-t border-border/60 bg-background/95 px-6 py-4">
|
||||
<Button
|
||||
data-testid="token-created-done"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className="max-w-lg overflow-hidden p-0"
|
||||
data-testid="create-token-dialog"
|
||||
>
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
|
||||
<DialogTitle>Create API token</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tokens allow agents and scripts to authenticate with the relay on
|
||||
your behalf.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="token-name">
|
||||
Name
|
||||
</label>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
data-testid="token-name-input"
|
||||
id="token-name"
|
||||
maxLength={100}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. my-agent-bot"
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">Scopes</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ALL_SCOPES.map(({ value, label }) => {
|
||||
const isSelected = selectedScopes.has(value);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/60 text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
data-testid={`token-scope-${value.replace(/:/g, "-")}`}
|
||||
key={value}
|
||||
onClick={() => toggleScope(value)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm font-medium">Channel access</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{channelAccessMode === "all"
|
||||
? "All accessible channels"
|
||||
: `${selectedChannelIds.size} selected`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{
|
||||
value: "all" as const,
|
||||
label: "All channels",
|
||||
description:
|
||||
"Unrestricted across the channels you can access.",
|
||||
},
|
||||
{
|
||||
value: "selected" as const,
|
||||
label: "Selected channels",
|
||||
description: "Limit this token to specific channels.",
|
||||
},
|
||||
].map((option) => {
|
||||
const isSelected = channelAccessMode === option.value;
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left text-sm transition-colors",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/60 text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
data-testid={`token-channel-access-${option.value}`}
|
||||
key={option.value}
|
||||
onClick={() => setChannelAccessMode(option.value)}
|
||||
type="button"
|
||||
>
|
||||
<p className="font-medium">{option.label}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{channelAccessMode === "selected" ? (
|
||||
isLoadingChannels ? (
|
||||
<p className="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
Loading channels...
|
||||
</p>
|
||||
) : channelsError ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{channelsError.message}
|
||||
</p>
|
||||
) : channels.length > 0 ? (
|
||||
<div className="max-h-52 space-y-2 overflow-y-auto rounded-xl border border-border/60 bg-muted/20 p-2">
|
||||
{channels.map((channel) => {
|
||||
const isSelected = selectedChannelIds.has(channel.id);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex w-full items-start justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
|
||||
isSelected
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-border/60 bg-background/70 hover:bg-accent",
|
||||
)}
|
||||
data-testid={`token-channel-${channel.id}`}
|
||||
key={channel.id}
|
||||
onClick={() => toggleChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{channel.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{channel.visibility} {channel.channelType}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isSelected ? "Selected" : "Select"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-xl border border-border/60 bg-muted/20 px-3 py-2 text-sm text-muted-foreground">
|
||||
No accessible channels available for scoping yet.
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use channel-scoped tokens for guests and single-purpose
|
||||
agents.
|
||||
</p>
|
||||
{currentPubkey ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only channels where you are a member can be added to a
|
||||
scoped token.
|
||||
{hiddenChannelsCount > 0
|
||||
? ` ${hiddenChannelsCount} accessible channel${hiddenChannelsCount === 1 ? "" : "s"} hidden because you are not a member.`
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your identity is still loading, so channel membership cannot
|
||||
be checked yet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-sm font-medium">Expiry</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EXPIRY_OPTIONS.map(({ value, label }) => (
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-sm transition-colors",
|
||||
expiryDays === value
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/60 text-muted-foreground hover:bg-accent",
|
||||
)}
|
||||
data-testid={`token-expiry-${value}`}
|
||||
key={value}
|
||||
onClick={() => setExpiryDays(value)}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTokenCount >= MAX_ACTIVE_TOKENS ? (
|
||||
<p className="rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
You already have {MAX_ACTIVE_TOKENS} active tokens. Revoke one
|
||||
before creating another.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{mintMutation.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{mintMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-border/60 bg-background/95 px-6 py-4">
|
||||
<Button
|
||||
data-testid="cancel-create-token"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="confirm-create-token"
|
||||
disabled={!canCreate}
|
||||
onClick={() => void handleCreate()}
|
||||
size="sm"
|
||||
>
|
||||
{mintMutation.isPending ? "Creating..." : "Create token"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function RevokeAllDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revoke all tokens?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will immediately revoke every active token. Agents using these
|
||||
tokens will lose access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{isPending ? "Revoking..." : "Revoke all"}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenSettingsCard({
|
||||
currentPubkey,
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
}) {
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const tokensQuery = useTokensQuery();
|
||||
const revokeTokenMutation = useRevokeTokenMutation();
|
||||
const revokeAllMutation = useRevokeAllTokensMutation();
|
||||
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const [revokeAllOpen, setRevokeAllOpen] = React.useState(false);
|
||||
|
||||
const allChannels = channelsQuery.data ?? [];
|
||||
const channels = allChannels.filter((channel) => channel.archivedAt === null);
|
||||
const scopeableChannelsQuery = useQuery({
|
||||
enabled:
|
||||
createOpen &&
|
||||
typeof currentPubkey === "string" &&
|
||||
currentPubkey.length > 0 &&
|
||||
channels.length > 0,
|
||||
queryKey: [
|
||||
"token-scopeable-channels",
|
||||
currentPubkey?.toLowerCase() ?? "",
|
||||
...channels.map((channel) => channel.id),
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!currentPubkey) {
|
||||
return [] as Channel[];
|
||||
}
|
||||
|
||||
const memberships = await Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
const members = await getChannelMembers(channel.id);
|
||||
return {
|
||||
channel,
|
||||
isMember: members.some(
|
||||
(member) =>
|
||||
member.pubkey.toLowerCase() === currentPubkey.toLowerCase(),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return memberships
|
||||
.filter((entry) => entry.isMember)
|
||||
.map((entry) => entry.channel);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const scopeableChannels = scopeableChannelsQuery.data ?? [];
|
||||
const hiddenChannelsCount = scopeableChannelsQuery.isSuccess
|
||||
? Math.max(channels.length - scopeableChannels.length, 0)
|
||||
: 0;
|
||||
const channelsById = new Map(
|
||||
allChannels.map((channel) => [channel.id, channel]),
|
||||
);
|
||||
const tokens = tokensQuery.data ?? [];
|
||||
const activeTokens = tokens.filter((t) => tokenStatus(t) === "active");
|
||||
const hasReachedTokenLimit = activeTokens.length >= MAX_ACTIVE_TOKENS;
|
||||
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
|
||||
data-testid="settings-tokens"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold tracking-tight">API Tokens</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create tokens for agents, guests, and integrations to access the
|
||||
relay. {activeTokens.length}/{MAX_ACTIVE_TOKENS} active.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 gap-2">
|
||||
{activeTokens.length > 0 ? (
|
||||
<Button
|
||||
onClick={() => setRevokeAllOpen(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Revoke all
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={hasReachedTokenLimit}
|
||||
onClick={() => setCreateOpen(true)}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create token
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasReachedTokenLimit ? (
|
||||
<p className="mt-3 rounded-xl border border-yellow-500/30 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-700 dark:text-yellow-400">
|
||||
You've reached the active token limit. Revoke an existing token to
|
||||
mint another.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tokensQuery.error instanceof Error ? (
|
||||
<p className="mt-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{tokensQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{tokens.length > 0 ? (
|
||||
<div className="mt-4 space-y-2">
|
||||
{tokens.map((token) => (
|
||||
<TokenRow
|
||||
channelsById={channelsById}
|
||||
isRevoking={revokeTokenMutation.isPending}
|
||||
key={token.id}
|
||||
onRevoke={(id) => revokeTokenMutation.mutate(id)}
|
||||
token={token}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : tokensQuery.isSuccess ? (
|
||||
<p className="mt-4 text-sm text-muted-foreground">
|
||||
No tokens yet. Create one to get started.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<CreateTokenDialog
|
||||
activeTokenCount={activeTokens.length}
|
||||
channels={scopeableChannels}
|
||||
channelsError={
|
||||
scopeableChannelsQuery.error instanceof Error
|
||||
? scopeableChannelsQuery.error
|
||||
: channelsQuery.error instanceof Error
|
||||
? channelsQuery.error
|
||||
: null
|
||||
}
|
||||
currentPubkey={currentPubkey}
|
||||
hiddenChannelsCount={hiddenChannelsCount}
|
||||
isLoadingChannels={
|
||||
channelsQuery.isLoading || scopeableChannelsQuery.isLoading
|
||||
}
|
||||
onOpenChange={setCreateOpen}
|
||||
open={createOpen}
|
||||
/>
|
||||
<RevokeAllDialog
|
||||
isPending={revokeAllMutation.isPending}
|
||||
onConfirm={() => {
|
||||
revokeAllMutation.mutate(undefined, {
|
||||
onSuccess: () => setRevokeAllOpen(false),
|
||||
});
|
||||
}}
|
||||
onOpenChange={setRevokeAllOpen}
|
||||
open={revokeAllOpen}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+145
-28
@@ -1,4 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
AddChannelMembersInput,
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
GetHomeFeedInput,
|
||||
HomeFeedResponse,
|
||||
Identity,
|
||||
MintTokenInput,
|
||||
MintTokenResponse,
|
||||
PresenceLookup,
|
||||
PresenceStatus,
|
||||
Profile,
|
||||
@@ -20,6 +22,8 @@ import type {
|
||||
SetPresenceResult,
|
||||
SetChannelPurposeInput,
|
||||
SetChannelTopicInput,
|
||||
Token,
|
||||
TokenScope,
|
||||
UpdateProfileInput,
|
||||
UpdateChannelInput,
|
||||
UserProfileSummary,
|
||||
@@ -146,6 +150,67 @@ type RawSearchResponse = {
|
||||
found: number;
|
||||
};
|
||||
|
||||
type RawToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TokenScope[];
|
||||
channel_ids: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
};
|
||||
|
||||
type RawListTokensResponse = {
|
||||
tokens: RawToken[];
|
||||
};
|
||||
|
||||
type RawMintTokenResponse = {
|
||||
id: string;
|
||||
token: string;
|
||||
name: string;
|
||||
scopes: TokenScope[];
|
||||
channel_ids: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
function toTauriError(error: unknown): Error {
|
||||
if (error instanceof Error) {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return new Error(error);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"message" in error &&
|
||||
typeof error.message === "string"
|
||||
) {
|
||||
return new Error(error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
return new Error(JSON.stringify(error));
|
||||
} catch {
|
||||
return new Error("Unknown Tauri error");
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeTauri<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await tauriInvoke<T>(command, args);
|
||||
} catch (error) {
|
||||
throw toTauriError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function fromRawChannel(channel: RawChannel): Channel {
|
||||
return {
|
||||
id: channel.id,
|
||||
@@ -235,7 +300,7 @@ function fromRawUserProfileSummary(
|
||||
}
|
||||
|
||||
export async function getIdentity(): Promise<Identity> {
|
||||
const identity = await invoke<RawIdentity>("get_identity");
|
||||
const identity = await invokeTauri<RawIdentity>("get_identity");
|
||||
|
||||
return {
|
||||
pubkey: identity.pubkey,
|
||||
@@ -244,26 +309,26 @@ export async function getIdentity(): Promise<Identity> {
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<Profile> {
|
||||
const profile = await invoke<RawProfile>("get_profile");
|
||||
const profile = await invokeTauri<RawProfile>("get_profile");
|
||||
return fromRawProfile(profile);
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
input: UpdateProfileInput,
|
||||
): Promise<Profile> {
|
||||
const profile = await invoke<RawProfile>("update_profile", input);
|
||||
const profile = await invokeTauri<RawProfile>("update_profile", input);
|
||||
return fromRawProfile(profile);
|
||||
}
|
||||
|
||||
export async function getUserProfile(pubkey?: string): Promise<Profile> {
|
||||
const profile = await invoke<RawProfile>("get_user_profile", { pubkey });
|
||||
const profile = await invokeTauri<RawProfile>("get_user_profile", { pubkey });
|
||||
return fromRawProfile(profile);
|
||||
}
|
||||
|
||||
export async function getUsersBatch(
|
||||
pubkeys: string[],
|
||||
): Promise<UsersBatchResponse> {
|
||||
const response = await invoke<RawUsersBatchResponse>("get_users_batch", {
|
||||
const response = await invokeTauri<RawUsersBatchResponse>("get_users_batch", {
|
||||
pubkeys,
|
||||
});
|
||||
|
||||
@@ -279,7 +344,7 @@ export async function getUsersBatch(
|
||||
}
|
||||
|
||||
export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
|
||||
const response = await invoke<RawPresenceLookup>("get_presence", {
|
||||
const response = await invokeTauri<RawPresenceLookup>("get_presence", {
|
||||
pubkeys,
|
||||
});
|
||||
|
||||
@@ -294,7 +359,7 @@ export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
|
||||
export async function setPresence(
|
||||
status: PresenceStatus,
|
||||
): Promise<SetPresenceResult> {
|
||||
const response = await invoke<RawSetPresenceResult>("set_presence", {
|
||||
const response = await invokeTauri<RawSetPresenceResult>("set_presence", {
|
||||
status,
|
||||
});
|
||||
|
||||
@@ -305,25 +370,25 @@ export async function setPresence(
|
||||
}
|
||||
|
||||
export function getRelayWsUrl(): Promise<string> {
|
||||
return invoke<string>("get_relay_ws_url");
|
||||
return invokeTauri<string>("get_relay_ws_url");
|
||||
}
|
||||
|
||||
export async function getChannels(): Promise<Channel[]> {
|
||||
const channels = await invoke<RawChannel[]>("get_channels");
|
||||
const channels = await invokeTauri<RawChannel[]>("get_channels");
|
||||
return channels.map(fromRawChannel);
|
||||
}
|
||||
|
||||
export async function createChannel(
|
||||
input: CreateChannelInput,
|
||||
): Promise<Channel> {
|
||||
const channel = await invoke<RawChannel>("create_channel", input);
|
||||
const channel = await invokeTauri<RawChannel>("create_channel", input);
|
||||
return fromRawChannel(channel);
|
||||
}
|
||||
|
||||
export async function getChannelDetails(
|
||||
channelId: string,
|
||||
): Promise<ChannelDetail> {
|
||||
const channel = await invoke<RawChannelDetail>("get_channel_details", {
|
||||
const channel = await invokeTauri<RawChannelDetail>("get_channel_details", {
|
||||
channelId,
|
||||
});
|
||||
return fromRawChannelDetail(channel);
|
||||
@@ -332,7 +397,7 @@ export async function getChannelDetails(
|
||||
export async function getChannelMembers(
|
||||
channelId: string,
|
||||
): Promise<ChannelMember[]> {
|
||||
const response = await invoke<RawChannelMembersResponse>(
|
||||
const response = await invokeTauri<RawChannelMembersResponse>(
|
||||
"get_channel_members",
|
||||
{
|
||||
channelId,
|
||||
@@ -344,59 +409,59 @@ export async function getChannelMembers(
|
||||
export async function updateChannel(
|
||||
input: UpdateChannelInput,
|
||||
): Promise<ChannelDetail> {
|
||||
const channel = await invoke<RawChannelDetail>("update_channel", input);
|
||||
const channel = await invokeTauri<RawChannelDetail>("update_channel", input);
|
||||
return fromRawChannelDetail(channel);
|
||||
}
|
||||
|
||||
export async function setChannelTopic(
|
||||
input: SetChannelTopicInput,
|
||||
): Promise<void> {
|
||||
await invoke("set_channel_topic", input);
|
||||
await invokeTauri("set_channel_topic", input);
|
||||
}
|
||||
|
||||
export async function setChannelPurpose(
|
||||
input: SetChannelPurposeInput,
|
||||
): Promise<void> {
|
||||
await invoke("set_channel_purpose", input);
|
||||
await invokeTauri("set_channel_purpose", input);
|
||||
}
|
||||
|
||||
export async function archiveChannel(channelId: string): Promise<void> {
|
||||
await invoke("archive_channel", { channelId });
|
||||
await invokeTauri("archive_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function unarchiveChannel(channelId: string): Promise<void> {
|
||||
await invoke("unarchive_channel", { channelId });
|
||||
await invokeTauri("unarchive_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function deleteChannel(channelId: string): Promise<void> {
|
||||
await invoke("delete_channel", { channelId });
|
||||
await invokeTauri("delete_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function addChannelMembers(
|
||||
input: AddChannelMembersInput,
|
||||
): Promise<AddChannelMembersResult> {
|
||||
return invoke<RawAddChannelMembersResult>("add_channel_members", input);
|
||||
return invokeTauri<RawAddChannelMembersResult>("add_channel_members", input);
|
||||
}
|
||||
|
||||
export async function removeChannelMember(
|
||||
channelId: string,
|
||||
pubkey: string,
|
||||
): Promise<void> {
|
||||
await invoke("remove_channel_member", { channelId, pubkey });
|
||||
await invokeTauri("remove_channel_member", { channelId, pubkey });
|
||||
}
|
||||
|
||||
export async function joinChannel(channelId: string): Promise<void> {
|
||||
await invoke("join_channel", { channelId });
|
||||
await invokeTauri("join_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function leaveChannel(channelId: string): Promise<void> {
|
||||
await invoke("leave_channel", { channelId });
|
||||
await invokeTauri("leave_channel", { channelId });
|
||||
}
|
||||
|
||||
export async function getHomeFeed(
|
||||
input: GetHomeFeedInput = {},
|
||||
): Promise<HomeFeedResponse> {
|
||||
const response = await invoke<RawHomeFeedResponse>("get_feed", input);
|
||||
const response = await invokeTauri<RawHomeFeedResponse>("get_feed", input);
|
||||
|
||||
return {
|
||||
feed: {
|
||||
@@ -416,7 +481,10 @@ export async function getHomeFeed(
|
||||
export async function searchMessages(
|
||||
input: SearchMessagesInput,
|
||||
): Promise<SearchMessagesResponse> {
|
||||
const response = await invoke<RawSearchResponse>("search_messages", input);
|
||||
const response = await invokeTauri<RawSearchResponse>(
|
||||
"search_messages",
|
||||
input,
|
||||
);
|
||||
|
||||
return {
|
||||
hits: response.hits.map(fromRawSearchHit),
|
||||
@@ -425,7 +493,7 @@ export async function searchMessages(
|
||||
}
|
||||
|
||||
export async function getEventById(eventId: string): Promise<RelayEvent> {
|
||||
const eventJson = await invoke<string>("get_event", { eventId });
|
||||
const eventJson = await invokeTauri<string>("get_event", { eventId });
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
@@ -434,7 +502,7 @@ export async function signRelayEvent(input: {
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}): Promise<RelayEvent> {
|
||||
const eventJson = await invoke<string>("sign_event", input);
|
||||
const eventJson = await invokeTauri<string>("sign_event", input);
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
@@ -442,6 +510,55 @@ export async function createAuthEvent(input: {
|
||||
challenge: string;
|
||||
relayUrl: string;
|
||||
}): Promise<RelayEvent> {
|
||||
const eventJson = await invoke<string>("create_auth_event", input);
|
||||
const eventJson = await invokeTauri<string>("create_auth_event", input);
|
||||
return JSON.parse(eventJson) as RelayEvent;
|
||||
}
|
||||
|
||||
function fromRawToken(token: RawToken): Token {
|
||||
return {
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
scopes: token.scopes,
|
||||
channelIds: token.channel_ids,
|
||||
createdAt: token.created_at,
|
||||
expiresAt: token.expires_at,
|
||||
lastUsedAt: token.last_used_at,
|
||||
revokedAt: token.revoked_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTokens(): Promise<Token[]> {
|
||||
const response = await invokeTauri<RawListTokensResponse>("list_tokens");
|
||||
return response.tokens.map(fromRawToken);
|
||||
}
|
||||
|
||||
export async function mintToken(
|
||||
input: MintTokenInput,
|
||||
): Promise<MintTokenResponse> {
|
||||
const response = await invokeTauri<RawMintTokenResponse>("mint_token", {
|
||||
name: input.name,
|
||||
scopes: input.scopes,
|
||||
channelIds: input.channelIds,
|
||||
expiresInDays: input.expiresInDays,
|
||||
});
|
||||
return {
|
||||
id: response.id,
|
||||
token: response.token,
|
||||
name: response.name,
|
||||
scopes: response.scopes,
|
||||
channelIds: response.channel_ids,
|
||||
createdAt: response.created_at,
|
||||
expiresAt: response.expires_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeToken(tokenId: string): Promise<void> {
|
||||
await invokeTauri("revoke_token", { tokenId });
|
||||
}
|
||||
|
||||
export async function revokeAllTokens(): Promise<{ revokedCount: number }> {
|
||||
const response = await invokeTauri<{ revoked_count: number }>(
|
||||
"revoke_all_tokens",
|
||||
);
|
||||
return { revokedCount: response.revoked_count };
|
||||
}
|
||||
|
||||
@@ -186,3 +186,40 @@ export type SearchMessagesResponse = {
|
||||
hits: SearchHit[];
|
||||
found: number;
|
||||
};
|
||||
|
||||
export type TokenScope =
|
||||
| "messages:read"
|
||||
| "messages:write"
|
||||
| "channels:read"
|
||||
| "channels:write"
|
||||
| "users:read"
|
||||
| "files:read"
|
||||
| "files:write";
|
||||
|
||||
export type Token = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: TokenScope[];
|
||||
channelIds: string[];
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
revokedAt: string | null;
|
||||
};
|
||||
|
||||
export type MintTokenInput = {
|
||||
name: string;
|
||||
scopes: TokenScope[];
|
||||
channelIds?: string[];
|
||||
expiresInDays?: number;
|
||||
};
|
||||
|
||||
export type MintTokenResponse = {
|
||||
id: string;
|
||||
token: string;
|
||||
name: string;
|
||||
scopes: TokenScope[];
|
||||
channelIds: string[];
|
||||
createdAt: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
@@ -12,11 +12,27 @@ type TestIdentity = {
|
||||
|
||||
type E2eConfig = {
|
||||
mode?: "mock" | "relay";
|
||||
mock?: {
|
||||
mintTokenError?: string;
|
||||
seededTokens?: RawMockTokenSeed[];
|
||||
};
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
identity?: TestIdentity;
|
||||
};
|
||||
|
||||
type RawMockTokenSeed = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
channel_ids: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type RawProfile = {
|
||||
pubkey: string;
|
||||
display_name: string | null;
|
||||
@@ -138,6 +154,33 @@ type RawSearchResponse = {
|
||||
found: number;
|
||||
};
|
||||
|
||||
type RawToken = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
channel_ids: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
};
|
||||
|
||||
type RawListTokensResponse = {
|
||||
tokens: RawToken[];
|
||||
};
|
||||
|
||||
type RawMintTokenResponse = RawToken & {
|
||||
token: string;
|
||||
};
|
||||
|
||||
type RawRevokeAllTokensResponse = {
|
||||
revoked_count: number;
|
||||
};
|
||||
|
||||
type MockToken = RawToken & {
|
||||
token: string;
|
||||
};
|
||||
|
||||
type WsHandler = (message: unknown) => void;
|
||||
|
||||
type MockSocket = {
|
||||
@@ -301,6 +344,35 @@ function cloneProfile(profile: RawProfile): RawProfile {
|
||||
return { ...profile };
|
||||
}
|
||||
|
||||
function cloneToken(token: RawToken): RawToken {
|
||||
return {
|
||||
...token,
|
||||
channel_ids: [...token.channel_ids],
|
||||
scopes: [...token.scopes],
|
||||
};
|
||||
}
|
||||
|
||||
function cloneMintedToken(token: MockToken): RawMintTokenResponse {
|
||||
return {
|
||||
...cloneToken(token),
|
||||
token: token.token,
|
||||
};
|
||||
}
|
||||
|
||||
function toMockToken(seed: RawMockTokenSeed): MockToken {
|
||||
return {
|
||||
...cloneToken(seed),
|
||||
token:
|
||||
seed.token ??
|
||||
`spr_tok_mock_${seed.id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function resetMockTokens(config: E2eConfig | undefined) {
|
||||
mockTokens = (config?.mock?.seededTokens ?? []).map(toMockToken);
|
||||
mockMintTokenError = config?.mock?.mintTokenError ?? null;
|
||||
}
|
||||
|
||||
function getMockProfileByPubkey(pubkey: string): RawProfile | null {
|
||||
const normalizedPubkey = pubkey.toLowerCase();
|
||||
const existing = mockProfiles.get(normalizedPubkey);
|
||||
@@ -566,6 +638,8 @@ const mockChannels: MockChannel[] = [
|
||||
const mockMessages = new Map<string, RelayEvent[]>();
|
||||
const mockSockets = new Map<number, MockSocket>();
|
||||
const realSockets = new Map<number, WebSocket>();
|
||||
let mockTokens: MockToken[] = [];
|
||||
let mockMintTokenError: string | null = null;
|
||||
const mockProfiles = new Map<string, RawProfile>([
|
||||
[
|
||||
MOCK_IDENTITY_PUBKEY,
|
||||
@@ -1536,6 +1610,114 @@ async function handleGetFeed(
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function handleListTokens(
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawListTokensResponse> {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
return {
|
||||
tokens: mockTokens.map(cloneToken),
|
||||
};
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawListTokensResponse>(config, "/api/tokens");
|
||||
}
|
||||
|
||||
async function handleMintToken(
|
||||
args: {
|
||||
name: string;
|
||||
scopes: string[];
|
||||
channelIds?: string[];
|
||||
expiresInDays?: number;
|
||||
},
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawMintTokenResponse> {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
if (mockMintTokenError) {
|
||||
throw mockMintTokenError;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const token: MockToken = {
|
||||
id: crypto.randomUUID(),
|
||||
name: args.name,
|
||||
scopes: [...args.scopes],
|
||||
channel_ids: [...(args.channelIds ?? [])],
|
||||
created_at: now.toISOString(),
|
||||
expires_at:
|
||||
typeof args.expiresInDays === "number"
|
||||
? new Date(
|
||||
now.getTime() + args.expiresInDays * 24 * 60 * 60 * 1_000,
|
||||
).toISOString()
|
||||
: null,
|
||||
last_used_at: null,
|
||||
revoked_at: null,
|
||||
token: `spr_tok_mock_${crypto.randomUUID().replace(/-/g, "")}`,
|
||||
};
|
||||
|
||||
mockTokens.unshift(token);
|
||||
return cloneMintedToken(token);
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawMintTokenResponse>(config, "/api/tokens", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: args.name,
|
||||
scopes: args.scopes,
|
||||
channel_ids: args.channelIds,
|
||||
expires_in_days: args.expiresInDays,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRevokeToken(
|
||||
args: { tokenId: string },
|
||||
config: E2eConfig | undefined,
|
||||
) {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const token = mockTokens.find((candidate) => candidate.id === args.tokenId);
|
||||
if (!token) {
|
||||
throw new Error(`Token ${args.tokenId} not found.`);
|
||||
}
|
||||
|
||||
token.revoked_at = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
await relayEmptyRequest(config, `/api/tokens/${args.tokenId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRevokeAllTokens(
|
||||
config: E2eConfig | undefined,
|
||||
): Promise<RawRevokeAllTokensResponse> {
|
||||
const identity = getIdentity(config);
|
||||
if (!identity) {
|
||||
const now = new Date().toISOString();
|
||||
let revokedCount = 0;
|
||||
|
||||
for (const token of mockTokens) {
|
||||
if (token.revoked_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
token.revoked_at = now;
|
||||
revokedCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
revoked_count: revokedCount,
|
||||
};
|
||||
}
|
||||
|
||||
return relayJsonRequest<RawRevokeAllTokensResponse>(config, "/api/tokens", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSearchMessages(
|
||||
args: {
|
||||
q: string;
|
||||
@@ -1831,6 +2013,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
return;
|
||||
}
|
||||
|
||||
resetMockTokens(config);
|
||||
mockWindows("main");
|
||||
window.__SPROUT_E2E_COMMANDS__ = [];
|
||||
window.__SPROUT_E2E_EMIT_MOCK_MESSAGE__ = ({ channelName, content }) => {
|
||||
@@ -1896,6 +2079,20 @@ export function maybeInstallE2eTauriMocks() {
|
||||
(payload as Parameters<typeof handleGetFeed>[0]) ?? {},
|
||||
activeConfig,
|
||||
);
|
||||
case "list_tokens":
|
||||
return handleListTokens(activeConfig);
|
||||
case "mint_token":
|
||||
return handleMintToken(
|
||||
payload as Parameters<typeof handleMintToken>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "revoke_token":
|
||||
return handleRevokeToken(
|
||||
payload as Parameters<typeof handleRevokeToken>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "revoke_all_tokens":
|
||||
return handleRevokeAllTokens(activeConfig);
|
||||
case "create_channel":
|
||||
return handleCreateChannel(
|
||||
payload as Parameters<typeof handleCreateChannel>[0],
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
|
||||
test("creates a channel-scoped token from settings and can revoke it", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
|
||||
const tokenCard = page.getByTestId("settings-tokens");
|
||||
await tokenCard.getByRole("button", { name: "Create token" }).click();
|
||||
|
||||
const dialog = page.getByTestId("create-token-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await page.getByTestId("token-name-input").fill("qa-selected-channels");
|
||||
await page.getByTestId("token-scope-messages-read").click();
|
||||
await page.getByTestId("token-scope-channels-read").click();
|
||||
await page.getByTestId("token-channel-access-selected").click();
|
||||
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Only channels where you are a member can be added to a scoped token. 3 accessible channels hidden because you are not a member.",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`token-channel-${GENERAL_CHANNEL_ID}`),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId(`token-channel-${RANDOM_CHANNEL_ID}`),
|
||||
).toHaveCount(0);
|
||||
|
||||
await page.getByTestId(`token-channel-${GENERAL_CHANNEL_ID}`).click();
|
||||
await page.getByTestId("token-expiry-7").click();
|
||||
await page.getByTestId("confirm-create-token").click();
|
||||
|
||||
const createdDialog = page.getByTestId("token-created-dialog");
|
||||
await expect(createdDialog).toBeVisible();
|
||||
await expect(createdDialog).toContainText("Token created");
|
||||
await expect(createdDialog).toContainText("spr_tok_mock_");
|
||||
await page.getByTestId("token-created-done").click();
|
||||
|
||||
await expect(tokenCard).toContainText("qa-selected-channels");
|
||||
await expect(tokenCard).toContainText("Scoped to 1 channel");
|
||||
await expect(tokenCard).toContainText("general");
|
||||
await tokenCard.locator('[data-testid^="revoke-token-"]').click();
|
||||
await expect(tokenCard).toContainText("revoked");
|
||||
});
|
||||
|
||||
test("surfaces token mint errors in the dialog", async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
mintTokenError:
|
||||
"relay returned 403 Forbidden: not a member of channel: 8f321c1d-f77e-4952-881c-f6e7bfb94c6b",
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByTestId("settings-tokens")
|
||||
.getByRole("button", { name: "Create token" })
|
||||
.click();
|
||||
|
||||
await page.getByTestId("token-name-input").fill("qa-failing-token");
|
||||
await page.getByTestId("token-scope-messages-read").click();
|
||||
await page.getByTestId("confirm-create-token").click();
|
||||
|
||||
const dialog = page.getByTestId("create-token-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog).toContainText(
|
||||
"relay returned 403 Forbidden: not a member of channel: 8f321c1d-f77e-4952-881c-f6e7bfb94c6b",
|
||||
);
|
||||
await expect(page.getByTestId("confirm-create-token")).toHaveText(
|
||||
"Create token",
|
||||
);
|
||||
});
|
||||
@@ -35,8 +35,24 @@ export const TEST_IDENTITIES = {
|
||||
|
||||
type BridgeMode = "mock" | "relay";
|
||||
|
||||
type MockBridgeOptions = {
|
||||
mintTokenError?: string;
|
||||
seededTokens?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
scopes: string[];
|
||||
channel_ids: string[];
|
||||
created_at: string;
|
||||
expires_at: string | null;
|
||||
last_used_at: string | null;
|
||||
revoked_at: string | null;
|
||||
token?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type BridgeOptions = {
|
||||
mode: BridgeMode;
|
||||
mock?: MockBridgeOptions;
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
user?: keyof typeof TEST_IDENTITIES;
|
||||
@@ -49,13 +65,14 @@ export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
: undefined;
|
||||
|
||||
await page.addInitScript(
|
||||
({ identity: bridgeIdentity, mode, relayHttpUrl, relayWsUrl }) => {
|
||||
({ identity: bridgeIdentity, mock, mode, relayHttpUrl, relayWsUrl }) => {
|
||||
(
|
||||
window as Window & {
|
||||
__SPROUT_E2E__?: Record<string, unknown>;
|
||||
}
|
||||
).__SPROUT_E2E__ = {
|
||||
identity: bridgeIdentity,
|
||||
mock,
|
||||
mode,
|
||||
relayHttpUrl,
|
||||
relayWsUrl,
|
||||
@@ -63,6 +80,7 @@ export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
},
|
||||
{
|
||||
identity,
|
||||
mock: options.mock,
|
||||
mode: options.mode,
|
||||
relayHttpUrl: options.relayHttpUrl,
|
||||
relayWsUrl: options.relayWsUrl,
|
||||
@@ -70,8 +88,8 @@ export async function installBridge(page: Page, options: BridgeOptions) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function installMockBridge(page: Page) {
|
||||
await installBridge(page, { mode: "mock" });
|
||||
export async function installMockBridge(page: Page, mock?: MockBridgeOptions) {
|
||||
await installBridge(page, { mode: "mock", mock });
|
||||
}
|
||||
|
||||
export async function installRelayBridge(
|
||||
|
||||
Reference in New Issue
Block a user