diff --git a/backend/README.md b/backend/README.md index 4d91b1a..6ecbdf3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -61,7 +61,7 @@ See [spec](spec) for more details All routes are NIP-98 protected. -- `GET /identity` — get auth identity (`pubkey`, `is_admin`, `is_tenant`) +- `GET /identity` — get auth identity (`pubkey`, `is_admin`) - `GET /tenants` — list tenants (admin) - `POST /tenants` — create current auth pubkey as tenant - `GET /tenants/:pubkey` — get tenant (admin or same tenant) diff --git a/backend/spec/api.md b/backend/spec/api.md index 4eae22d..6a796c2 100644 --- a/backend/spec/api.md +++ b/backend/spec/api.md @@ -13,7 +13,7 @@ Members: Notes: - Authentication is done using NIP 98 comparing `u` to `self.host`, not the incoming request -- Each route is responsible for authorization using `self.is_admin(pubkey)` or `self.is_tenant(authorized_pubkey, tenant_pubkey)` +- Each route is responsible for authorization using `self.require_admin` or `self.require_admin_or_tenant` - Successful API responses should be of the form `{data, code: "ok"}` with an appropriate http status code. - Unsuccessful API responses should be of the form `{error, code}` with an appropriate http status code. `code` is a short error code (e.g. `duplicate-subdomain`) and `error` is a human-readable error message. @@ -46,6 +46,7 @@ Notes: - Serves `GET /identity` - Authorizes anyone, but must be authorized +- If a tenant for the identity doesn't exist, one is created - Return `data` is an `Identity` struct --- Tenant routes @@ -62,14 +63,6 @@ Notes: - Authorizes admin or matching tenant - Return `data` is a single tenant struct from `repo.get_tenant` -## `async fn create_tenant(...) -> Response` - -- Serves `POST /tenants` -- Authorizes anyone, but must be authorized -- Creates a new tenant using `repo.create_tenant` based on the authorized pubkey -- If tenant is a duplicate, return a `422` with `code=pubkey-exists` -- Return `data` is a single tenant struct. Use HTTP `201`. - ## `async fn list_tenant_relays(...) -> Response` - Serves `GET /tenants/:pubkey/relays` diff --git a/backend/spec/models.md b/backend/spec/models.md index 7911ffc..7edebb6 100644 --- a/backend/spec/models.md +++ b/backend/spec/models.md @@ -9,7 +9,6 @@ Identity is a description of a user. - `pubkey` - the user's nostr pubkey - `is_admin` - whether the user is an admin -- `is_tenant` - whether the user has an account # Activity diff --git a/backend/src/api.rs b/backend/src/api.rs index b7d2737..dae26ae 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -103,7 +103,7 @@ impl Api { .route("/identity", get(get_identity)) .route("/plans", get(list_plans)) .route("/plans/:id", get(get_plan)) - .route("/tenants", get(list_tenants).post(create_tenant)) + .route("/tenants", get(list_tenants)) .route("/tenants/:pubkey", get(get_tenant)) .route("/tenants/:pubkey/relays", get(list_tenant_relays)) .route("/tenants/:pubkey/invoices", get(list_tenant_invoices)) @@ -297,7 +297,6 @@ struct UpdateTenantBillingRequest { struct IdentityResponse { pubkey: String, is_admin: bool, - is_tenant: bool, } #[derive(Deserialize)] @@ -365,10 +364,16 @@ async fn get_identity( ) -> std::result::Result { let pubkey = state.api.extract_auth_pubkey(&headers)?; let is_admin = state.api.admins.iter().any(|a| a == &pubkey); + let tenant = Tenant { + pubkey: pubkey.clone(), + nwc_url: String::new(), + created_at: now_ts(), + billing_anchor: now_ts(), + }; - let is_tenant = match state.api.repo.get_tenant(&pubkey).await { - Ok(Some(_)) => true, - Ok(None) => false, + match state.api.repo.create_tenant(&tenant).await { + Ok(()) => true, + Err(e) if matches!(map_unique_error(&e), Some("pubkey-exists")) => true, Err(e) => { return Ok(err( StatusCode::INTERNAL_SERVER_ERROR, @@ -383,7 +388,6 @@ async fn get_identity( IdentityResponse { pubkey, is_admin, - is_tenant, }, )) } @@ -420,39 +424,6 @@ async fn get_tenant( } } -async fn create_tenant( - State(state): State, - headers: HeaderMap, -) -> std::result::Result { - let pubkey = state.api.extract_auth_pubkey(&headers)?; - - let tenant = Tenant { - pubkey: pubkey.clone(), - nwc_url: String::new(), - created_at: now_ts(), - billing_anchor: now_ts(), - }; - - match state.api.repo.create_tenant(&tenant).await { - Ok(()) => Ok(ok(StatusCode::CREATED, tenant)), - Err(e) => { - if matches!(map_unique_error(&e), Some("pubkey-exists")) { - Ok(err( - StatusCode::UNPROCESSABLE_ENTITY, - "pubkey-exists", - "tenant already exists", - )) - } else { - Ok(err( - StatusCode::INTERNAL_SERVER_ERROR, - "internal", - &e.to_string(), - )) - } - } - } -} - async fn list_relays( State(state): State, headers: HeaderMap, @@ -967,20 +938,24 @@ mod tests { } #[tokio::test] - async fn create_tenant_and_duplicate_returns_expected_codes() { + async fn identity_creates_tenant_if_missing() { let repo = test_repo().await; let (admin_keys, tenant_keys, _) = keys(); - let api = make_api(repo, pubkey_hex(&admin_keys)); + let api = make_api(repo.clone(), pubkey_hex(&admin_keys)); let auth = auth_header(&tenant_keys, "https://api.test"); + let tenant_pubkey = pubkey_hex(&tenant_keys); - let (status, body) = request(&api, "POST", "/tenants", Some(auth.clone()), None).await; - assert_eq!(status, StatusCode::CREATED); + let (status, body) = request(&api, "GET", "/identity", Some(auth), None).await; + assert_eq!(status, StatusCode::OK); assert_eq!(body["code"], "ok"); - assert_eq!(body["data"]["pubkey"], pubkey_hex(&tenant_keys)); + assert_eq!(body["data"]["pubkey"], tenant_pubkey); + assert_eq!(body["data"]["is_admin"], false); - let (status, body) = request(&api, "POST", "/tenants", Some(auth), None).await; - assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); - assert_eq!(body["code"], "pubkey-exists"); + let tenant = repo + .get_tenant(&tenant_pubkey) + .await + .expect("lookup tenant after identity"); + assert!(tenant.is_some()); } #[tokio::test] diff --git a/frontend/spec/lib/api.md b/frontend/spec/lib/api.md index 0516968..36c6db6 100644 --- a/frontend/spec/lib/api.md +++ b/frontend/spec/lib/api.md @@ -20,7 +20,7 @@ The api allows the frontend to access the database. Most endpoints are authentic - Calls `GET /identity` - Requires authentication -- Returns `{ pubkey, is_admin, is_tenant }` for the authorized user +- Returns `{ pubkey, is_admin }` for the authorized user ## Plan methods diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d96073f..73937d9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import AdminTenantDetail from "./pages/admin/AdminTenantDetail" import AdminRelayList from "./pages/admin/AdminRelayList" import AdminRelayDetail from "./pages/admin/AdminRelayDetail" import AdminRelayEdit from "./pages/admin/AdminRelayEdit" -import { account, identity } from "./lib/hooks" +import { identity } from "./lib/hooks" function Layout(props: { children?: any }) { const location = useLocation() @@ -32,7 +32,7 @@ function Layout(props: { children?: any }) { return (
- {props.children}}> + {props.children}}> {props.children}
@@ -56,8 +56,8 @@ export default function App() { } } - const requireAdmin = (Page: Component) => requireCondition(Page, () => Boolean(account() && identity()?.is_admin)) - const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(account() && identity()?.is_tenant)) + const requireAdmin = (Page: Component) => requireCondition(Page, () => Boolean(identity()?.is_admin)) + const requireTenant = (Page: Component) => requireCondition(Page, () => Boolean(identity())) return ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 0f94f55..0d9acbd 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -76,7 +76,6 @@ export type Invoice = { export type Identity = { pubkey: string is_admin: boolean - is_tenant: boolean } export type CreateRelayInput = { diff --git a/frontend/src/lib/hooks.ts b/frontend/src/lib/hooks.ts index 2dc2c8e..173fd43 100644 --- a/frontend/src/lib/hooks.ts +++ b/frontend/src/lib/hooks.ts @@ -26,6 +26,7 @@ import { type CreateRelayInput, type Relay, type Tenant, + type Identity, type UpdateRelayInput, } from "./api" @@ -141,12 +142,6 @@ export function primeProfiles(pubkeys: string[]) { } } -type Identity = { - pubkey: string - is_admin: boolean - is_tenant: boolean -} - export const useTenant = () => createResource(() => getTenant(account()!.pubkey)) export const useTenantRelays = () => createResource(() => listTenantRelays(account()!.pubkey)) diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 54ae88f..f773f8f 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,4 +1,4 @@ -import { Show, createSignal, onCleanup } from "solid-js" +import { Show, createSignal, createEffect, onCleanup } from "solid-js" import { useNavigate } from "@solidjs/router" import { ExtensionAccount, NostrConnectAccount, PasswordAccount, PrivateKeyAccount } from "applesauce-accounts/accounts" import { PasswordSigner } from "applesauce-signers" @@ -56,16 +56,9 @@ export default function Login() { let scanner: QrScanner | undefined let abortController: AbortController | undefined - async function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { + function completeLogin(account: ExtensionAccount | NostrConnectAccount | PrivateKeyAccount | PasswordAccount) { accountManager.addAccount(account) accountManager.setActive(account) - - const dispose = createEffect(() => { - if (identity()) { - navigate("/relays") - dispose() - } - }) } async function loginWithNip07() { @@ -196,6 +189,12 @@ export default function Login() { void navigator.clipboard.writeText(nostrConnectUri()) } + createEffect(() => { + if (identity()) { + navigate("/relays") + } + }) + onCleanup(() => { abortController?.abort() scanner?.destroy()