diff --git a/api/src/api/http/routes.rs b/api/src/api/http/routes.rs index 1c78d96..a6a5c51 100644 --- a/api/src/api/http/routes.rs +++ b/api/src/api/http/routes.rs @@ -34,6 +34,10 @@ fn protected_routes(pool: SqlitePool) -> Router { "/teams/:id/keys/:pubkey/authorizations", post(teams::add_authorization), ) + .route( + "/teams/:id/keys/:pubkey/authorizations/:authorization_id", + delete(teams::remove_authorization), + ) .route("/teams/:id/policies", post(teams::add_policy)) .layer(middleware::from_fn(auth_middleware)) .with_state(pool) diff --git a/api/src/api/http/teams.rs b/api/src/api/http/teams.rs index cc5b8c2..2d4a242 100644 --- a/api/src/api/http/teams.rs +++ b/api/src/api/http/teams.rs @@ -564,6 +564,7 @@ pub async fn add_authorization( Json(request): Json, ) -> ApiResult> { verify_admin(&pool, &event.pubkey, team_id).await?; + let authorization_name = normalized_authorization_name(request.name.as_deref()); let stored_key_public_key = PublicKey::from_hex(&pubkey).map_err(|e| ApiError::bad_request(e.to_string()))?; @@ -612,12 +613,13 @@ pub async fn add_authorization( // Create authorization let authorization = sqlx::query_as::query_as::<_, Authorization>( r#" - INSERT INTO authorizations (stored_key_id, policy_id, secret, bunker_public_key, bunker_secret, relays, max_uses, expires_at, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now')) + INSERT INTO authorizations (stored_key_id, name, policy_id, secret, bunker_public_key, bunker_secret, relays, max_uses, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'), datetime('now')) RETURNING * "#, ) .bind(stored_key.id) + .bind(authorization_name) .bind(request.policy_id) .bind(secret) .bind(bunker_keys.public_key().to_hex()) @@ -633,6 +635,59 @@ pub async fn add_authorization( Ok(Json(authorization)) } +pub async fn remove_authorization( + State(pool): State, + AuthEvent(event): AuthEvent, + Path((team_id, pubkey, authorization_id)): Path<(u32, String, u32)>, +) -> ApiResult { + verify_admin(&pool, &event.pubkey, team_id).await?; + + let stored_key_public_key = + PublicKey::from_hex(&pubkey).map_err(|e| ApiError::bad_request(e.to_string()))?; + + let mut tx = pool.begin().await?; + + let existing_authorization_id: Option = sqlx::query_scalar::query_scalar( + r#" + SELECT a.id + FROM authorizations a + JOIN stored_keys sk ON sk.id = a.stored_key_id + WHERE a.id = ?1 + AND sk.team_id = ?2 + AND sk.public_key = ?3 + "#, + ) + .bind(authorization_id) + .bind(team_id) + .bind(stored_key_public_key.to_hex()) + .fetch_optional(&mut *tx) + .await?; + + if existing_authorization_id.is_none() { + return Err(ApiError::not_found("Authorization not found")); + } + + sqlx::query::query("DELETE FROM user_authorizations WHERE authorization_id = ?1") + .bind(authorization_id) + .execute(&mut *tx) + .await?; + + sqlx::query::query("DELETE FROM authorizations WHERE id = ?1") + .bind(authorization_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(StatusCode::NO_CONTENT) +} + +fn normalized_authorization_name(name: Option<&str>) -> Option { + name.map(str::trim) + .filter(|trimmed| !trimmed.is_empty()) + .map(ToOwned::to_owned) +} + pub async fn add_policy( State(pool): State, AuthEvent(event): AuthEvent, @@ -732,10 +787,26 @@ pub async fn verify_teammate<'a>( #[cfg(test)] mod tests { use super::*; + use crate::state::{KeycastState, KEYCAST_STATE}; use axum::extract::Path; + use keycast_core::encryption::{KeyManager, KeyManagerError}; use keycast_core::types::user::TeamUserRole; use sqlx::raw_sql::raw_sql; use sqlx_sqlite::SqlitePoolOptions; + use std::sync::Arc; + + struct TestKeyManager; + + #[async_trait::async_trait] + impl KeyManager for TestKeyManager { + async fn encrypt(&self, plaintext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(plaintext_bytes.to_vec()) + } + + async fn decrypt(&self, ciphertext_bytes: &[u8]) -> Result, KeyManagerError> { + Ok(ciphertext_bytes.to_vec()) + } + } async fn setup_test_db() -> SqlitePool { let pool = SqlitePoolOptions::new() @@ -754,6 +825,17 @@ mod tests { .execute(&pool) .await .unwrap(); + raw_sql(include_str!( + "../../../../database/migrations/0003_add_authorization_name.sql" + )) + .execute(&pool) + .await + .unwrap(); + + let _ = KEYCAST_STATE.set(Arc::new(KeycastState { + db: pool.clone(), + key_manager: Box::new(TestKeyManager), + })); pool } @@ -961,6 +1043,7 @@ mod tests { AuthEvent(auth_event(&admin)), Path((first_team.team.id, stored_key.public_key().to_hex())), Json(AddAuthorizationRequest { + name: Some("Other team policy".to_string()), policy_id: second_team.policies[0].policy.id, relays: vec!["wss://relay.example".to_string()], max_uses: None, @@ -974,6 +1057,212 @@ mod tests { assert_eq!(count_rows(&pool, "authorizations").await, 0); } + #[tokio::test] + async fn add_authorization_persists_trimmed_name() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + let stored_key = Keys::generate(); + + sqlx::query::query( + "INSERT INTO stored_keys (name, team_id, public_key, secret_key, created_at, updated_at) + VALUES ('key', ?1, ?2, ?3, datetime('now'), datetime('now'))", + ) + .bind(team.team.id) + .bind(stored_key.public_key().to_hex()) + .bind(vec![1_u8, 2, 3]) + .execute(&pool) + .await + .unwrap(); + + let authorization = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some(" Alice tablet ".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + + assert_eq!(authorization.name.as_deref(), Some("Alice tablet")); + + let stored_name: Option = + sqlx::query_scalar::query_scalar("SELECT name FROM authorizations WHERE id = ?1") + .bind(authorization.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored_name.as_deref(), Some("Alice tablet")); + } + + #[tokio::test] + async fn remove_authorization_deletes_selected_authorization_and_redemptions_only() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + let stored_key = Keys::generate(); + let auth_user = Keys::generate(); + + sqlx::query::query( + "INSERT INTO stored_keys (name, team_id, public_key, secret_key, created_at, updated_at) + VALUES ('key', ?1, ?2, ?3, datetime('now'), datetime('now'))", + ) + .bind(team.team.id) + .bind(stored_key.public_key().to_hex()) + .bind(vec![1_u8, 2, 3]) + .execute(&pool) + .await + .unwrap(); + + let first = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some("Alice phone".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + let second = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some("Bob laptop".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + + sqlx::query::query( + "INSERT INTO users (public_key, created_at, updated_at) + VALUES (?1, datetime('now'), datetime('now'))", + ) + .bind(auth_user.public_key().to_hex()) + .execute(&pool) + .await + .unwrap(); + sqlx::query::query( + "INSERT INTO user_authorizations (user_public_key, authorization_id, created_at, updated_at) + VALUES (?1, ?2, datetime('now'), datetime('now'))", + ) + .bind(auth_user.public_key().to_hex()) + .bind(first.id) + .execute(&pool) + .await + .unwrap(); + + let status = remove_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, stored_key.public_key().to_hex(), first.id)), + ) + .await + .unwrap(); + + assert_eq!(status, StatusCode::NO_CONTENT); + let first_count: i64 = + sqlx::query_scalar::query_scalar("SELECT COUNT(*) FROM authorizations WHERE id = ?1") + .bind(first.id) + .fetch_one(&pool) + .await + .unwrap(); + let second_count: i64 = + sqlx::query_scalar::query_scalar("SELECT COUNT(*) FROM authorizations WHERE id = ?1") + .bind(second.id) + .fetch_one(&pool) + .await + .unwrap(); + let redemption_count: i64 = sqlx::query_scalar::query_scalar( + "SELECT COUNT(*) FROM user_authorizations WHERE authorization_id = ?1", + ) + .bind(first.id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(first_count, 0); + assert_eq!(second_count, 1); + assert_eq!(redemption_count, 0); + } + + #[tokio::test] + async fn remove_authorization_rejects_authorization_from_another_key_path() { + let pool = setup_test_db().await; + let admin = Keys::generate(); + let team = create_team_for(&pool, &admin, "Ops").await; + let first_key = Keys::generate(); + let second_key = Keys::generate(); + + for key in [&first_key, &second_key] { + sqlx::query::query( + "INSERT INTO stored_keys (name, team_id, public_key, secret_key, created_at, updated_at) + VALUES ('key', ?1, ?2, ?3, datetime('now'), datetime('now'))", + ) + .bind(team.team.id) + .bind(key.public_key().to_hex()) + .bind(vec![1_u8, 2, 3]) + .execute(&pool) + .await + .unwrap(); + } + + let authorization = add_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path((team.team.id, first_key.public_key().to_hex())), + Json(AddAuthorizationRequest { + name: Some("Alice phone".to_string()), + policy_id: team.policies[0].policy.id, + relays: vec!["wss://relay.example".to_string()], + max_uses: None, + expires_at: None, + }), + ) + .await + .unwrap() + .0; + + let err = remove_authorization( + State(pool.clone()), + AuthEvent(auth_event(&admin)), + Path(( + team.team.id, + second_key.public_key().to_hex(), + authorization.id, + )), + ) + .await + .unwrap_err(); + + assert!(matches!(err, ApiError::NotFound(_))); + let authorization_count: i64 = + sqlx::query_scalar::query_scalar("SELECT COUNT(*) FROM authorizations WHERE id = ?1") + .bind(authorization.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(authorization_count, 1); + } + #[tokio::test] async fn delete_team_removes_related_rows_without_orphaning_permissions() { let pool = setup_test_db().await; diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 46c5eca..84090be 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -40,6 +40,8 @@ pub struct CreatePolicyRequest { #[derive(Debug, Deserialize)] pub struct AddAuthorizationRequest { + #[serde(default)] + pub name: Option, pub policy_id: u32, pub relays: Vec, pub max_uses: Option, diff --git a/core/src/database.rs b/core/src/database.rs index 5d9b96e..21cd2c2 100644 --- a/core/src/database.rs +++ b/core/src/database.rs @@ -158,6 +158,7 @@ mod tests { &migrations_dir, "0002_normalize_allowed_kinds_permissions.sql", ); + copy_migration(&migrations_dir, "0003_add_authorization_name.sql"); let database = Database::new(db_path.clone(), migrations_dir.clone()) .await @@ -169,7 +170,7 @@ mod tests { .fetch_all(pool) .await .expect("read migration versions"); - assert_eq!(migration_versions, vec![1, 2]); + assert_eq!(migration_versions, vec![1, 2, 3]); let migrated_config: serde_json::Value = query_scalar( "SELECT config FROM permissions @@ -195,8 +196,8 @@ mod tests { .expect("read current-shape config"); assert_eq!(current_shape_config, json!({ "allowed_kinds": [9735] })); - let existing_authorization: (String, i64, String) = query_as( - "SELECT secret, max_uses, bunker_public_key FROM authorizations WHERE secret = ?", + let existing_authorization: (String, i64, String, Option) = query_as( + "SELECT secret, max_uses, bunker_public_key, name FROM authorizations WHERE secret = ?", ) .bind("existing-secret") .fetch_one(pool) @@ -207,7 +208,8 @@ mod tests { ( "existing-secret".to_string(), 10, - "existing-bunker-pubkey".to_string() + "existing-bunker-pubkey".to_string(), + None ) ); @@ -227,7 +229,7 @@ mod tests { .fetch_all(&database.pool) .await .expect("read migration versions after second open"); - assert_eq!(migration_versions, vec![1, 2]); + assert_eq!(migration_versions, vec![1, 2, 3]); database.pool.close().await; let _ = fs::remove_dir_all(root); diff --git a/core/src/types/authorization.rs b/core/src/types/authorization.rs index 207557c..2c7c22b 100644 --- a/core/src/types/authorization.rs +++ b/core/src/types/authorization.rs @@ -71,6 +71,8 @@ pub struct Authorization { pub id: u32, /// The id of the stored key the authorization belongs to pub stored_key_id: u32, + /// A human-readable label for remembering what the authorization is for + pub name: Option, /// The generated secret connection uuid pub secret: String, /// The public key of the bunker nostr secret key @@ -102,6 +104,7 @@ impl<'r> FromRow<'r, SqliteRow> for Authorization { Ok(Self { id: row.try_get("id")?, stored_key_id: row.try_get("stored_key_id")?, + name: row.try_get("name")?, secret: row.try_get("secret")?, bunker_public_key: row.try_get("bunker_public_key")?, bunker_secret: row.try_get("bunker_secret")?, @@ -476,6 +479,12 @@ mod tests { .execute(&pool) .await .unwrap(); + raw_sql(include_str!( + "../../../database/migrations/0003_add_authorization_name.sql" + )) + .execute(&pool) + .await + .unwrap(); pool } @@ -520,12 +529,13 @@ mod tests { sqlx::query_as::query_as::<_, Authorization>( r#" INSERT INTO authorizations - (stored_key_id, secret, bunker_public_key, bunker_secret, relays, policy_id, max_uses, expires_at, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, datetime('now'), datetime('now')) + (stored_key_id, name, secret, bunker_public_key, bunker_secret, relays, policy_id, max_uses, expires_at, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, datetime('now'), datetime('now')) RETURNING * "#, ) .bind(stored_key_id) + .bind(Option::::None) .bind(format!("test_secret_{}", uuid::Uuid::new_v4())) .bind(keys.public_key().to_hex()) .bind(keys.secret_key().to_secret_bytes().to_vec()) diff --git a/database/migrations/0003_add_authorization_name.sql b/database/migrations/0003_add_authorization_name.sql new file mode 100644 index 0000000..3190abf --- /dev/null +++ b/database/migrations/0003_add_authorization_name.sql @@ -0,0 +1,2 @@ +ALTER TABLE authorizations +ADD COLUMN name TEXT; diff --git a/web/src/lib/components/AuthorizationCard.svelte b/web/src/lib/components/AuthorizationCard.svelte index c4ca546..125ea36 100644 --- a/web/src/lib/components/AuthorizationCard.svelte +++ b/web/src/lib/components/AuthorizationCard.svelte @@ -1,12 +1,19 @@
-

{authorization.authorization.secret}

+ {#if authorization.authorization.name} +
+

{authorization.authorization.name}

+

{authorization.authorization.secret}

+
+ {:else} +

{authorization.authorization.secret}

+ {/if} +
Redemptions: {authorization.users.length} / {authorization.authorization.max_uses || "∞"} diff --git a/web/src/lib/components/SignInMenu.svelte b/web/src/lib/components/SignInMenu.svelte index 16efd55..ea4a178 100644 --- a/web/src/lib/components/SignInMenu.svelte +++ b/web/src/lib/components/SignInMenu.svelte @@ -3,6 +3,7 @@ import { createNostrConnectSigninSession, hasNip07Extension, isAmberSigninSupported, + type NostrConnectSigninOptions, type NostrConnectSigninSession, } from "$lib/nostr"; import { @@ -22,7 +23,12 @@ import { X, } from "phosphor-svelte"; -type BusyState = SigninMethod | "nostr-connect-link" | null; +type BusyState = SigninMethod | "nostr-connect-link" | "amber" | null; +type ConnectSessionStartOptions = { + autoOpen?: boolean; + busyState: Exclude; + signerKind?: NostrConnectSigninOptions["signerKind"]; +}; let open = $state(false); let bunkerUri = $state(""); @@ -72,15 +78,33 @@ async function submitBunker(event: SubmitEvent) { } async function startConnectLink() { + await startConnectSession({ busyState: "nostr-connect-link" }); +} + +async function startAmberConnect() { + await startConnectSession({ + autoOpen: true, + busyState: "amber", + signerKind: "amber", + }); +} + +async function startConnectSession(options: ConnectSessionStartOptions) { cancelConnectLink(); connectError = null; connectCopied = false; - connectSession = createNostrConnectSigninSession(); + connectSession = createNostrConnectSigninSession({ + signerKind: options.signerKind, + }); connectUri = connectSession.uri; const controller = new AbortController(); connectController = controller; - busy = "nostr-connect-link"; + busy = options.busyState; + + if (options.autoOpen) { + openConnectUri(connectUri); + } try { const user = await connectSession.waitForUser(controller.signal); @@ -94,13 +118,21 @@ async function startConnectLink() { error instanceof Error ? error.message : "Unable to connect signer"; } } finally { - if (connectController === controller) { + const shouldClearConnectState = + connectController === controller || + (controller.signal.aborted && connectController === null); + + if (shouldClearConnectState) { busy = null; connectController = null; } } } +function openConnectUri(uri: string) { + window.location.href = uri; +} + function cancelConnectLink() { connectController?.abort(); connectSession?.cancel(); @@ -251,19 +283,19 @@ async function copyConnectUri() {
diff --git a/web/src/lib/nostr.ts b/web/src/lib/nostr.ts index 0436082..e126134 100644 --- a/web/src/lib/nostr.ts +++ b/web/src/lib/nostr.ts @@ -5,15 +5,10 @@ import type { ProfileContent, } from "applesauce-core/helpers"; import { normalizeToPubkey, npubEncode } from "applesauce-core/helpers"; -import { getEventHash, verifyEvent } from "applesauce-core/helpers/event"; import { createEventLoaderForStore } from "applesauce-loaders/loaders"; import { RelayPool } from "applesauce-relay"; import type { ISigner } from "applesauce-signers"; -import { - AmberClipboardSigner, - ExtensionSigner, - NostrConnectSigner, -} from "applesauce-signers"; +import { ExtensionSigner, NostrConnectSigner } from "applesauce-signers"; import { catchError, filter, firstValueFrom, of, timeout } from "rxjs"; import { DEFAULT_NOSTR_READ_RELAYS, @@ -41,10 +36,13 @@ export type NostrConnectSigninSession = { waitForUser: (abort?: AbortSignal) => Promise; cancel: () => void; }; +export type NostrConnectSigninOptions = { + relays?: readonly string[]; + signerKind?: "nostr-connect" | "amber"; +}; const PROFILE_LOAD_TIMEOUT_MS = 5000; const CONTACTS_LOAD_TIMEOUT_MS = 5000; -const HEX_SIGNATURE = /^[0-9a-f]{128}$/i; export const DEFAULT_NOSTR_CONNECT_RELAYS = [ "wss://relay.nsec.app", ...REQUIRED_PUBLIC_RELAYS, @@ -99,7 +97,7 @@ export function hasNip07Extension(): boolean { export function isAmberSigninSupported(): boolean { return ( typeof navigator !== "undefined" && - /Android/i.test(navigator.userAgent) + /\bAndroid\b/i.test(navigator.userAgent) ); } @@ -137,10 +135,6 @@ export async function getExtensionUser(): Promise { return userFromSigner(getExtensionSigner(), "extension"); } -export async function getAmberUser(): Promise { - return userFromSigner(new ManualAmberSigner(), "amber"); -} - export async function connectNostrConnectBunker( bunkerUri: string, ): Promise { @@ -154,8 +148,10 @@ export async function connectNostrConnectBunker( } export function createNostrConnectSigninSession( - relays: readonly string[] = DEFAULT_NOSTR_CONNECT_RELAYS, + options: NostrConnectSigninOptions = {}, ): NostrConnectSigninSession { + const relays = options.relays ?? DEFAULT_NOSTR_CONNECT_RELAYS; + const signerKind = options.signerKind ?? "nostr-connect"; const signer = new NostrConnectSigner({ relays: [...relays], pool: relayPool, @@ -171,7 +167,7 @@ export function createNostrConnectSigninSession( uri, waitForUser: async (abort?: AbortSignal) => { await signer.waitForSigner(abort); - return userFromSigner(signer, "nostr-connect"); + return userFromSigner(signer, signerKind); }, cancel: () => { void signer.close(); @@ -255,138 +251,6 @@ async function userFromSigner(signer: ISigner, kind: SignerKind): Promise { - if (!isAmberSigninSupported()) { - throw new Error("Amber signing is only available on Android"); - } - - if (this.pubkey) return this.pubkey; - - const result = await requestAmberResult( - AmberClipboardSigner.createGetPublicKeyIntent(), - "public key", - (value) => normalizePubkey(value) !== null, - ); - const pubkey = normalizePubkey(result); - if (!pubkey) throw new Error("Expected Amber to return a pubkey"); - - this.pubkey = pubkey; - return pubkey; - } - - async signEvent( - template: EventTemplate & { pubkey?: string }, - ): Promise { - if (!isAmberSigninSupported()) { - throw new Error("Amber signing is only available on Android"); - } - - const signerPubkey = template.pubkey ?? this.pubkey; - const pubkey = signerPubkey ? normalizePubkey(signerPubkey) : null; - if (!pubkey) throw new Error("Unknown Amber signer pubkey"); - - const draftWithPubkey = { ...template, pubkey }; - const draftWithId = { - ...draftWithPubkey, - id: getEventHash(draftWithPubkey), - }; - const result = await requestAmberResult( - AmberClipboardSigner.createSignEventIntent( - draftWithId as EventTemplate, - ), - "signature", - (value) => HEX_SIGNATURE.test(value), - ); - const signature = result.trim(); - const event = { ...draftWithId, sig: signature }; - if (!verifyEvent(event)) { - throw new Error("Amber returned an invalid signature"); - } - - return event; - } -} - -async function requestAmberResult( - intent: string, - label: string, - accepts: (value: string) => boolean, -): Promise { - if (typeof window === "undefined" || typeof document === "undefined") { - throw new Error("Amber signing requires a browser"); - } - - window.open(intent, "_blank"); - const returnedFromAmber = await waitForBrowserToReturn(); - - if (returnedFromAmber) { - const clipboardResult = (await readClipboardText()).trim(); - if (clipboardResult && accepts(clipboardResult)) { - return clipboardResult; - } - } - - const manualResult = window.prompt(`Paste the Amber ${label} result`); - const trimmedManualResult = manualResult?.trim() ?? ""; - if (trimmedManualResult && accepts(trimmedManualResult)) { - return trimmedManualResult; - } - - throw new Error(`Amber ${label} result was invalid or not provided`); -} - -async function waitForBrowserToReturn(): Promise { - return new Promise((resolve) => { - let sawHidden = document.visibilityState === "hidden"; - let settled = false; - - const done = (returnedFromAmber: boolean) => { - if (settled) return; - settled = true; - document.removeEventListener("visibilitychange", onVisibilityChange); - window.removeEventListener("focus", onFocus); - resolve(returnedFromAmber); - }; - - const onVisibilityChange = () => { - if (document.visibilityState === "hidden") { - sawHidden = true; - } else if (sawHidden) { - setTimeout(() => done(true), 250); - } - }; - - const onFocus = () => { - if (sawHidden) { - setTimeout(() => done(true), 250); - } - }; - - document.addEventListener("visibilitychange", onVisibilityChange); - window.addEventListener("focus", onFocus); - setTimeout(() => { - if (!sawHidden && document.visibilityState === "visible") { - done(false); - } - }, 1500); - }); -} - -async function readClipboardText(): Promise { - try { - if (navigator.clipboard?.readText) { - return await navigator.clipboard.readText(); - } - } catch { - // Android browsers often require a user gesture before granting clipboard access. - } - - return ""; -} - function disposeSigner(signer: ISigner | null | undefined): void { const disposable = signer as | { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 39d0cdf..3b86b80 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -17,6 +17,7 @@ export type User = { export type Authorization = { id: number; stored_key_id: number; + name: string | null; secret: string; bunker_nsec: string; relays: string[]; diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index f6b7e29..5fd49a9 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -3,15 +3,13 @@ import { getCurrentUser, setCurrentUser } from "$lib/current_user.svelte"; import { clearActiveSigner, connectNostrConnectBunker, - getAmberUser, getExtensionUser, - isAmberSigninSupported, type NostrUser, } from "$lib/nostr"; import toast from "svelte-hot-french-toast"; import { checkPubkeyAllowed } from "./allowlist"; -export type SigninMethod = "extension" | "nip46-bunker" | "amber"; +export type SigninMethod = "extension" | "nip46-bunker"; export type SigninOptions = { bunkerUri?: string; }; @@ -72,12 +70,6 @@ async function userFromSigninMethod( return null; } return connectNostrConnectBunker(options.bunkerUri); - case "amber": - if (!isAmberSigninSupported()) { - toast.error("Amber sign-in is only available on supported Android browsers"); - return null; - } - return getAmberUser(); default: method satisfies never; return null; diff --git a/web/src/lib/utils/nostr.test.ts b/web/src/lib/utils/nostr.test.ts index 21ade76..e28e116 100644 --- a/web/src/lib/utils/nostr.test.ts +++ b/web/src/lib/utils/nostr.test.ts @@ -3,6 +3,7 @@ import type { EventTemplate, NostrEvent } from "applesauce-core/helpers"; import { buildNip46SigningPermissions, clearActiveSigner, + createNostrConnectSigninSession, getActiveSignerSummary, npubForPubkey, normalizeBunkerUri, @@ -65,6 +66,18 @@ describe("Nostr helper utilities", () => { ]); }); + test("creates Amber sign-in sessions through Nostr Connect", () => { + const session = createNostrConnectSigninSession({ signerKind: "amber" }); + const uri = new URL(session.uri); + + session.cancel(); + + expect(uri.protocol).toBe("nostrconnect:"); + expect(uri.searchParams.get("perms")).toBe( + "get_public_key,sign_event:27235", + ); + }); + test("signs events with the active signer", async () => { const template = authTemplate(); const signedEvent = signedAuthEvent(PUBKEY); diff --git a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte index 88257f4..4862435 100644 --- a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte +++ b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/+page.svelte @@ -98,6 +98,33 @@ async function removeKey() { toast.error("Failed to remove key"); }); } + +async function revokeAuthorization(authorization: AuthorizationWithRelations) { + if (!user?.pubkey) return; + if (!confirm("Revoke this authorization? Existing clients using it will lose access.")) + return; + + const authorizationId = authorization.authorization.id; + const endpoint = `/teams/${id}/keys/${pubkey}/authorizations/${authorizationId}`; + + try { + const authHeader = await api.buildAuthHeader(endpoint, "DELETE", user.pubkey); + + await api.delete(endpoint, { + headers: { + Authorization: authHeader, + }, + }); + + authorizations = authorizations.filter( + (item) => item.authorization.id !== authorizationId, + ); + toast.success("Authorization revoked"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toast.error(`Failed to revoke authorization: ${message}`); + } +} {#if isLoading} @@ -147,7 +174,10 @@ async function removeKey() { {:else}
{#each authorizations as authorization} - + {/each}
{/if} diff --git a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte index c8ecd18..3c4965d 100644 --- a/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte +++ b/web/src/routes/(app)/teams/[id]/keys/[pubkey]/authorizations/new/+page.svelte @@ -23,6 +23,7 @@ const user = $derived(getCurrentUser()?.user); let isLoading = $state(true); let teamAuthHeader: string | null = $state(null); +let authorizationName = $state(""); let maxUses: number | null = $state(0); let expiresAt: Date | null = $state(null); let relaysString: string = $state(relayListForInput()); @@ -79,7 +80,9 @@ async function createAuthorization() { return; } + const name = authorizationName.trim(); const request = { + name: name || null, max_uses: maxUses === 0 ? null : maxUses, expires_at: expiresAt ? Math.floor(new Date(expiresAt).getTime() / 1000) @@ -121,6 +124,16 @@ async function createAuthorization() {
{ event.preventDefault(); createAuthorization(); }}> +
+ + +
+