From a14bda4bb92a02defebd6077c567f1c83deefc2d Mon Sep 17 00:00:00 2001 From: Mykola Melnyk Date: Thu, 8 Oct 2026 20:01:34 +0200 Subject: [PATCH] Email claim links to the owner, and let staff pick an existing account Minting a claim link used to hand staff a URL to send themselves. Now the server emails it to the address it was minted for, and staff can choose that address from existing accounts instead of typing it. - New `claim_invite` email, sent by `claims.email_claim_invite` from `staff.create_claim_invite`, so the staff route and the MCP tool both send it. It goes out inside the request, never through django-q: task arguments are kept in the database, and the link is a credential that never expires and is otherwise stored only as a hash. `claim_code` works the same way. - The response (route and MCP) carries `emailed`. A failed send is logged and reported, not raised: the invite still exists and staff still hold the link, so the panel tells them to send it by hand. - `GET /catalog/staff/users?q=` (staff only, active accounts, matched on email or name, at most 10) backs a picker under the panel's email field; choosing an account fills in its address. Redemption is unchanged. - DEVELOPMENT.md and CLAUDE.md describe the email and why it is not queued. --- CLAUDE.md | 3 +- DEVELOPMENT.md | 10 +- backend/apps/catalog/claims.py | 18 ++ backend/apps/catalog/mcp.py | 8 +- backend/apps/catalog/schemas.py | 12 +- backend/apps/catalog/staff.py | 16 ++ backend/apps/catalog/staff_api.py | 10 + .../templates/email/claim_invite/body.html | 207 ++++++++++++++++++ .../templates/email/claim_invite/body.mjml | 19 ++ .../templates/email/claim_invite/body.txt | 13 ++ .../templates/email/claim_invite/preview.json | 4 + .../templates/email/claim_invite/subject.txt | 1 + backend/openapi.json | 72 ++++++ backend/tests/test_catalog_claim_invites.py | 64 +++++- .../staff/claim-invite-minter.test.tsx | 69 +++++- .../catalog/staff/claim-invite-minter.tsx | 50 ++++- .../generated/catalog-staff/catalog-staff.ts | 113 +++++++++- frontend/src/lib/api/generated/model/index.ts | 2 + .../generated/model/staffClaimInviteOut.ts | 2 + .../generated/model/staffFindUsersParams.ts | 11 + .../lib/api/generated/model/staffUserOut.ts | 13 ++ 21 files changed, 695 insertions(+), 22 deletions(-) create mode 100644 backend/apps/core/templates/email/claim_invite/body.html create mode 100644 backend/apps/core/templates/email/claim_invite/body.mjml create mode 100644 backend/apps/core/templates/email/claim_invite/body.txt create mode 100644 backend/apps/core/templates/email/claim_invite/preview.json create mode 100644 backend/apps/core/templates/email/claim_invite/subject.txt create mode 100644 frontend/src/lib/api/generated/model/staffFindUsersParams.ts create mode 100644 frontend/src/lib/api/generated/model/staffUserOut.ts diff --git a/CLAUDE.md b/CLAUDE.md index f9b35f9..b257c58 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,7 +135,8 @@ The parts that bite if you miss them: is what actually refuses writes. - **A staff claim link grants a listing with no review** (`ToolClaimInvite`, `/claim/`). Only its hash is stored, it never expires and it redeems once. The off switch is the admin's - revoke action, not a TTL. + revoke action, not a TTL. It is emailed **in the request, never queued** - django-q keeps task + arguments in the database, which would store the raw link. ## Owner badges diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 2bf6b57..a436cb8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -249,7 +249,10 @@ Things to know: Staff can hand a listing straight to its owner. **Create claim link** in the staff panel (or the `catalog_create_claim_invite` MCP tool) takes the owner's email and returns a URL of the form -`/claim/`. Staff send it themselves: the server sends no mail. The owner signs +`/claim/`. The server emails it to that address (`claim_invite`) and reports +`emailed`; when that is false, staff send the link themselves. In the panel, typing part of a name or +email also offers existing accounts (`GET /catalog/staff/users`), and picking one fills in its +address. The owner signs in on that page and accepts, which gives them an **approved** `ToolClaim` on the spot. There is no code to enter and no review, because sending the link was the review. @@ -292,6 +295,7 @@ production. Tests always use the in-memory backend. | `submission_received` | a tool is submitted (`POST /catalog/submissions`) | `contact_email`, else the account | | `submission_staff_notice` | the same, with a link to the row in the admin | every active `is_staff` account | | `claim_code` | a vendor claims a listing | the claimant's work email | +| `claim_invite` | staff mint a claim link | the address the link was minted for | | `benchmark_sent` | a benchmark submission is sent (`finalize`) | the submitter | | `benchmark_scored` | scoring settles: waiting for review, or failed with each case's error | the submitter | | `benchmark_staff_notice` | the same, only when it is waiting for review; links to the admin via `BACKEND_URL` | every active `is_staff` account | @@ -309,7 +313,9 @@ re-queued with the admin's **Re-score** action, `services.rescore`. It runs only submission that is scoring, failed or awaiting review, and settles it - emails included - like any other. -All but `claim_code` go through the django-q2 queue after the transaction commits, so a +`claim_code` and `claim_invite` are sent inside the request, never queued: a task's arguments are +kept in the database, and both carry a credential the server otherwise stores only as a hash. All +the others go through the django-q2 queue after the transaction commits, so a sign-in or a submission never waits on - or fails with - the mail provider, and a rolled-back request mails nobody. On the host that means they only leave while `make backend-worker` runs (or with `Q_SYNC=true` in `backend/.env`); the Docker stack diff --git a/backend/apps/catalog/claims.py b/backend/apps/catalog/claims.py index 78ca18c..d90902e 100644 --- a/backend/apps/catalog/claims.py +++ b/backend/apps/catalog/claims.py @@ -128,6 +128,24 @@ def issue_claim_invite(*, tool, email, user): return invite, f"{settings.FRONTEND_URL}/claim/{raw}" +def email_claim_invite(invite, url) -> bool: + """Send the link to its recipient. Returns whether the mail went out. + + Sent in the request, never through the queue: a task's arguments are kept in + the database, and the link is a credential that never expires. A failure is + reported rather than raised, because the caller still holds the link and can + send it by hand. + """ + try: + send_templated_email( + "claim_invite", {"tool_name": invite.tool.name, "url": url}, to=[invite.email] + ) + except Exception: + logger.exception("Could not email claim invite %s", invite.pk) + return False + return True + + def find_claim_invite(raw): """The live invite behind a link, or None. A revoked one is as good as unknown.""" return ( diff --git a/backend/apps/catalog/mcp.py b/backend/apps/catalog/mcp.py index 9aeeba4..3cc063a 100644 --- a/backend/apps/catalog/mcp.py +++ b/backend/apps/catalog/mcp.py @@ -544,7 +544,7 @@ class RemoveToolFacetResult(Struct): class CreateClaimInviteParams(Struct): slug: SLUG - email: Annotated[str, Meta(description="The owner's address, where you will send the link.")] + email: Annotated[str, Meta(description="The owner's address. The link is emailed there.")] class ClaimInviteResult(Struct): @@ -552,6 +552,9 @@ class ClaimInviteResult(Struct): tool: str email: str url: Annotated[str, Meta(description="The only copy of the link: it is not stored.")] + emailed: Annotated[ + bool, Meta(description="Whether we emailed it. If false, send the link yourself.") + ] created_at: str @@ -1006,7 +1009,8 @@ def catalog_remove_tool_facet(request, params: ToolFacetParams) -> RemoveToolFac "Mint a one-time claim link for a listing's owner. Whoever opens it " "while signed in becomes an approved maintainer with no further " "review, so send it only to someone you have verified. It never " - "expires; this tool does not send mail - pass the url on yourself. " + "expires. It is emailed to the address; if `emailed` is false, " + "pass the url on yourself. " "A link sent to the wrong person is revoked in the admin." ), read_only=False, diff --git a/backend/apps/catalog/schemas.py b/backend/apps/catalog/schemas.py index 4f2a6d4..67dfe2d 100644 --- a/backend/apps/catalog/schemas.py +++ b/backend/apps/catalog/schemas.py @@ -7,8 +7,9 @@ from datetime import date, datetime from decimal import Decimal +from uuid import UUID -from ninja import Schema +from ninja import Field, Schema class VendorOut(Schema): @@ -567,6 +568,12 @@ class StaffClaimInviteIn(Schema): email: str +class StaffUserOut(Schema): + id: UUID + email: str + name: str + + class StaffClaimInviteOut(Schema): """Carries the only copy of the link: the server keeps its hash.""" @@ -574,6 +581,9 @@ class StaffClaimInviteOut(Schema): tool: str email: str url: str + emailed: bool = Field( + ..., description="False when the mail failed: staff send the link by hand." + ) created_at: datetime diff --git a/backend/apps/catalog/staff.py b/backend/apps/catalog/staff.py index e6e558d..2760429 100644 --- a/backend/apps/catalog/staff.py +++ b/backend/apps/catalog/staff.py @@ -12,6 +12,7 @@ from decimal import Decimal, InvalidOperation +from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.core.validators import validate_email from django.db import IntegrityError, transaction @@ -910,6 +911,20 @@ def _screenshot(shot): # --- Claim links --------------------------------------------------------------- +def find_users(*, q, limit=10): + """Active accounts whose email or name contains `q`, for choosing an invite's recipient.""" + q = (q or "").strip() + if not q: + return [] + found = ( + get_user_model() + .objects.filter(is_active=True) + .filter(Q(email__icontains=q) | Q(name__icontains=q)) + .order_by("email")[:limit] + ) + return [{"id": account.pk, "email": account.email, "name": account.name} for account in found] + + def create_claim_invite(*, user, slug, email): """Mint a one-time link that grants this listing to whoever redeems it. @@ -930,5 +945,6 @@ def create_claim_invite(*, user, slug, email): "tool": tool.slug, "email": invite.email, "url": url, + "emailed": claims.email_claim_invite(invite, url), "created_at": invite.created_at.isoformat(), } diff --git a/backend/apps/catalog/staff_api.py b/backend/apps/catalog/staff_api.py index 95b4480..853c272 100644 --- a/backend/apps/catalog/staff_api.py +++ b/backend/apps/catalog/staff_api.py @@ -33,6 +33,7 @@ StaffToolReviewIn, StaffToolReviewOut, StaffToolUpdateOut, + StaffUserOut, ) router = Router(tags=["catalog-staff"], auth=StaffJWTAuth()) @@ -190,6 +191,15 @@ def staff_upload_tool_logo(request: HttpRequest, slug: str, image: File[Uploaded # --- Claim links ------------------------------------------------------------- +@router.get( + "/users", + response=list[StaffUserOut], + summary="Find accounts by email or name, to choose a claim link's recipient", +) +def staff_find_users(request: HttpRequest, q: str = ""): + return staff.find_users(q=q) + + @router.post( "/tools/{slug}/claim-invites", response={201: StaffClaimInviteOut}, diff --git a/backend/apps/core/templates/email/claim_invite/body.html b/backend/apps/core/templates/email/claim_invite/body.html new file mode 100644 index 0000000..7dfa056 --- /dev/null +++ b/backend/apps/core/templates/email/claim_invite/body.html @@ -0,0 +1,207 @@ + + + + + Maintain the {{ tool_name }} listing on Redaction Tools + + + + + + + + + + + + + + + + + +
You have been invited to maintain the {{ tool_name }} listing
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + +
+
Our team has invited you to maintain the {{ tool_name }} listing on Redaction Tools.
+
+ + + + + + +
+ Accept the invitation +
+
+
Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it.
+
+
The link works once, for whoever accepts it first, so please do not forward it. If you were not expecting this, you can ignore it.
+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+
You received this because of an action on Redaction Tools.
+
+
+
+ +
+
+ +
+ + + \ No newline at end of file diff --git a/backend/apps/core/templates/email/claim_invite/body.mjml b/backend/apps/core/templates/email/claim_invite/body.mjml new file mode 100644 index 0000000..5ecce45 --- /dev/null +++ b/backend/apps/core/templates/email/claim_invite/body.mjml @@ -0,0 +1,19 @@ + + + Maintain the {{ tool_name }} listing on Redaction Tools + You have been invited to maintain the {{ tool_name }} listing + + + + + + + Our team has invited you to maintain the {{ tool_name }} listing on Redaction Tools. + Accept the invitation + Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it. + The link works once, for whoever accepts it first, so please do not forward it. If you were not expecting this, you can ignore it. + + + + + diff --git a/backend/apps/core/templates/email/claim_invite/body.txt b/backend/apps/core/templates/email/claim_invite/body.txt new file mode 100644 index 0000000..c24fdf3 --- /dev/null +++ b/backend/apps/core/templates/email/claim_invite/body.txt @@ -0,0 +1,13 @@ +{% autoescape off %}Our team has invited you to maintain the {{ tool_name }} listing on Redaction Tools. + +Accept here: +{{ url }} + +Sign in with Google, then accept. As a maintainer you can propose corrections to the listing's pricing and details, and the listing shows that its vendor maintains it. + +The link works once, for whoever accepts it first, so please do not forward it. If you were not expecting this, you can ignore it. + +-- +Redaction Tools +{{ site_url }} +{% endautoescape %} diff --git a/backend/apps/core/templates/email/claim_invite/preview.json b/backend/apps/core/templates/email/claim_invite/preview.json new file mode 100644 index 0000000..d614c34 --- /dev/null +++ b/backend/apps/core/templates/email/claim_invite/preview.json @@ -0,0 +1,4 @@ +{ + "tool_name": "Adobe Acrobat", + "url": "https://redaction-tools.com/claim/preview-token-not-a-real-link" +} diff --git a/backend/apps/core/templates/email/claim_invite/subject.txt b/backend/apps/core/templates/email/claim_invite/subject.txt new file mode 100644 index 0000000..05851e2 --- /dev/null +++ b/backend/apps/core/templates/email/claim_invite/subject.txt @@ -0,0 +1 @@ +{% autoescape off %}Maintain the {{ tool_name }} listing on Redaction Tools{% endautoescape %} diff --git a/backend/openapi.json b/backend/openapi.json index 5128843..f526b59 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2203,6 +2203,48 @@ ] } }, + "/api/v1/catalog/staff/users": { + "get": { + "operationId": "staff_find_users", + "summary": "Find accounts by email or name, to choose a claim link's recipient", + "parameters": [ + { + "in": "query", + "name": "q", + "schema": { + "default": "", + "title": "Q", + "type": "string" + }, + "required": false + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StaffUserOut" + }, + "title": "Response", + "type": "array" + } + } + } + } + }, + "tags": [ + "catalog-staff" + ], + "security": [ + { + "StaffJWTAuth": [] + } + ] + } + }, "/api/v1/catalog/staff/tools/{slug}/claim-invites": { "post": { "operationId": "staff_create_claim_invite", @@ -6644,6 +6686,30 @@ "title": "StaffToolLogoOut", "type": "object" }, + "StaffUserOut": { + "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "email": { + "title": "Email", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + } + }, + "required": [ + "id", + "email", + "name" + ], + "title": "StaffUserOut", + "type": "object" + }, "StaffClaimInviteOut": { "description": "Carries the only copy of the link: the server keeps its hash.", "properties": { @@ -6663,6 +6729,11 @@ "title": "Url", "type": "string" }, + "emailed": { + "description": "False when the mail failed: staff send the link by hand.", + "title": "Emailed", + "type": "boolean" + }, "created_at": { "format": "date-time", "title": "Created At", @@ -6674,6 +6745,7 @@ "tool", "email", "url", + "emailed", "created_at" ], "title": "StaffClaimInviteOut", diff --git a/backend/tests/test_catalog_claim_invites.py b/backend/tests/test_catalog_claim_invites.py index f384f1f..5cb391f 100644 --- a/backend/tests/test_catalog_claim_invites.py +++ b/backend/tests/test_catalog_claim_invites.py @@ -1,6 +1,6 @@ """Staff-issued claim links. -Staff mint a link for a tool's owner and email it themselves. It never expires +Staff mint a link for a tool's owner, and the server emails it to them. It never expires but works once, and redeeming it grants the listing outright: the vetting a claim normally waits on is the staff member choosing whom to send it to. """ @@ -65,6 +65,32 @@ def test_staff_mint_a_link_to_the_frontend_claim_page(staff_user): assert row.redeemed_at is None +def test_minting_emails_the_link_to_the_owner(staff_user, mailoutbox): + result = staff.create_claim_invite(user=staff_user, slug=SEEDED, email="ceo@vendor.example") + + assert result["emailed"] is True + [message] = mailoutbox + assert message.to == ["ceo@vendor.example"] + assert "PDF Redaction" in message.subject + assert result["url"] in message.body + assert result["url"] in message.alternatives[0][0] + + +def test_a_mail_failure_still_hands_staff_the_link(staff_user, monkeypatch): + """The link is the only copy, so staff must get it even when the mail fails.""" + + def refuse(*args, **kwargs): + raise OSError("SMTP relay down") + + monkeypatch.setattr("apps.catalog.claims.send_templated_email", refuse) + + result = staff.create_claim_invite(user=staff_user, slug=SEEDED, email="ceo@vendor.example") + + assert result["emailed"] is False + assert result["url"] + assert ToolClaimInvite.objects.count() == 1 + + def test_only_the_tokens_hash_is_stored(invite): """A database read must not be enough to take over a listing.""" token = _token(invite) @@ -102,6 +128,8 @@ def test_the_staff_route_mints_a_link(client, staff_bearer): assert response.status_code == 201 assert "/claim/" in response.json()["url"] + # So the panel can say whether staff still have to send it themselves. + assert response.json()["emailed"] is True def test_a_non_staff_account_cannot_mint_one(client, bearer): @@ -122,7 +150,39 @@ def test_the_mcp_tool_mints_a_link(call_tool): ) assert result["isError"] is False, result - assert "/claim/" in json.loads(result["content"][0]["text"])["url"] + payload = json.loads(result["content"][0]["text"]) + assert "/claim/" in payload["url"] + assert payload["emailed"] is True + + +# --- Choosing the recipient --------------------------------------------------- + +USERS = "/api/v1/catalog/staff/users" + + +def test_staff_can_find_an_account_by_email_or_name(client, staff_bearer, django_user_model): + django_user_model.objects.create_user(email="jeff@blinded.example", name="Jeff Nixon") + django_user_model.objects.create_user(email="someone@else.example", name="Someone") + + by_email = client.get(USERS, {"q": "blinded"}, **staff_bearer).json() + by_name = client.get(USERS, {"q": "nixon"}, **staff_bearer).json() + + assert [found["email"] for found in by_email] == ["jeff@blinded.example"] + assert by_name == by_email + assert by_email[0]["name"] == "Jeff Nixon" + + +def test_only_staff_can_search_accounts(client, bearer): + """The list is every account's email: an owner must not be able to read it.""" + response = client.get(USERS, {"q": "example"}, **bearer) + + assert response.status_code == 403 + + +def test_the_search_skips_deactivated_accounts(client, staff_bearer, django_user_model): + django_user_model.objects.create_user(email="gone@vendor.example", is_active=False) + + assert client.get(USERS, {"q": "gone"}, **staff_bearer).json() == [] # --- Previewing -------------------------------------------------------------- diff --git a/frontend/src/features/catalog/staff/claim-invite-minter.test.tsx b/frontend/src/features/catalog/staff/claim-invite-minter.test.tsx index ee27031..a1fa85a 100644 --- a/frontend/src/features/catalog/staff/claim-invite-minter.test.tsx +++ b/frontend/src/features/catalog/staff/claim-invite-minter.test.tsx @@ -13,16 +13,28 @@ const INVITE = { tool: 'adobe-acrobat', email: 'ceo@adobe.com', url: URL, + emailed: true, created_at: '2026-09-29T00:00:00Z', } -function stubFetch(status: number, body: unknown) { - return vi.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }), - ) +function json(status: number, body: unknown) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +/** Answers the mint with `body` and every account search with `accounts`. */ +function stubFetch(status: number, body: unknown, accounts: unknown[] = []) { + return vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async (input) => + String(input).includes('/catalog/staff/users') ? json(200, accounts) : json(status, body), + ) +} + +function mintCalls(fetch: ReturnType) { + return fetch.mock.calls.filter(([url]) => String(url).includes('/claim-invites')) } async function mint(user: ReturnType) { @@ -43,11 +55,30 @@ describe('ClaimInviteMinter', () => { await mint(user) - const [url, init] = fetch.mock.calls[0] + const [url, init] = mintCalls(fetch)[0] expect(String(url)).toContain('/catalog/staff/tools/adobe-acrobat/claim-invites') expect(JSON.parse(String(init?.body))).toEqual({ email: 'ceo@adobe.com' }) }) + // Owners who already signed in once have an account: staff pick it rather + // than retype an address and risk a typo in a credential's destination. + it('fills the address from an existing account staff pick', async () => { + const fetch = stubFetch(201, INVITE, [ + { id: 'a1', email: 'jeff@blinded.example', name: 'Jeff Nixon' }, + ]) + const user = userEvent.setup() + renderWithProviders() + + await user.click(screen.getByRole('button', { name: /create claim link/i })) + await user.type(screen.getByLabelText(/owner's email/i), 'jeff') + await user.click(await screen.findByRole('button', { name: /jeff nixon/i })) + expect(screen.getByLabelText(/owner's email/i)).toHaveValue('jeff@blinded.example') + await user.click(screen.getByRole('button', { name: /create link/i })) + + const [, init] = mintCalls(fetch)[0] + expect(JSON.parse(String(init?.body))).toEqual({ email: 'jeff@blinded.example' }) + }) + it('shows the link once minted, ready to copy or email', async () => { stubFetch(201, INVITE) const user = userEvent.setup() @@ -62,6 +93,28 @@ describe('ClaimInviteMinter', () => { expect(decodeURIComponent(email.getAttribute('href') ?? '')).toContain(URL) }) + it('says the link went out by email', async () => { + stubFetch(201, INVITE) + const user = userEvent.setup() + renderWithProviders() + + await mint(user) + + expect(await screen.findByRole('status')).toHaveTextContent('Emailed to ceo@adobe.com') + }) + + // The link is not stored anywhere, so when the mail fails this panel is the + // only way it reaches the owner. + it('tells staff to send it themselves when the email failed', async () => { + stubFetch(201, { ...INVITE, emailed: false }) + const user = userEvent.setup() + renderWithProviders() + + await mint(user) + + expect(await screen.findByRole('alert')).toHaveTextContent(/could not email it/i) + }) + it('warns that the link is shown only once', async () => { stubFetch(201, INVITE) const user = userEvent.setup() diff --git a/frontend/src/features/catalog/staff/claim-invite-minter.tsx b/frontend/src/features/catalog/staff/claim-invite-minter.tsx index be8f528..db8d26d 100644 --- a/frontend/src/features/catalog/staff/claim-invite-minter.tsx +++ b/frontend/src/features/catalog/staff/claim-invite-minter.tsx @@ -5,7 +5,10 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { CardDescription } from '@/components/ui/card' import { Input } from '@/components/ui/input' -import { useStaffCreateClaimInvite } from '@/lib/api/generated/catalog-staff/catalog-staff' +import { + useStaffCreateClaimInvite, + useStaffFindUsers, +} from '@/lib/api/generated/catalog-staff/catalog-staff' import { EditorActions } from './editor-actions' @@ -20,10 +23,12 @@ function mailto(email: string, toolName: string, url: string) { } /** - * Mints a one-time link that makes its recipient an approved maintainer. + * Mints a one-time link that makes its recipient an approved maintainer, and + * has the server email it to them. * * The server keeps only the link's hash, so this is the one place it is ever - * shown; a lost link means minting another and revoking the first in the admin. + * shown - and the only way it reaches the owner when the email fails. A lost + * link means minting another and revoking the first in the admin. */ export function ClaimInviteMinter({ slug, toolName }: { slug: string; toolName: string }) { const [open, setOpen] = useState(false) @@ -31,11 +36,28 @@ export function ClaimInviteMinter({ slug, toolName }: { slug: string; toolName: const [copied, setCopied] = useState(false) const create = useStaffCreateClaimInvite() const invite = create.data + const query = email.trim() + const accounts = useStaffFindUsers( + { q: query }, + { query: { enabled: open && !invite && query.length >= 2 } }, + ) + // Once an account is chosen the field holds its exact address; offering it + // again would only repeat what is already there. + const matches = (accounts.data ?? []).filter((account) => account.email !== query) if (invite) { return (

Claim link for {invite.email}

+ {invite.emailed ? ( +

+ Emailed to {invite.email}. Copy it below if you also want to send it yourself. +

+ ) : ( +

+ We could not email it. Copy the link or open it in your email and send it yourself. +

+ )}
setEmail(event.target.value)} /> + {matches.length ? ( +
    + {matches.map((account) => ( +
  • + +
  • + ))} +
+ ) : null}

- Recorded as the owner's work email. Send the link only to someone you have verified. + Type an address, or part of a name or email to pick an existing account. Recorded as the + owner's work email. Send the link only to someone you have verified.

=> { return useMutation(getStaffUploadToolLogoMutationOptions(options), queryClient); } - export const getStaffCreateClaimInviteUrl = (slug: string,) => { + export const getStaffFindUsersUrl = (params?: StaffFindUsersParams,) => { + const normalizedParams = new URLSearchParams(); + + Object.entries(params || {}).forEach(([key, value]) => { + + if (value !== undefined) { + normalizedParams.append(key, value === null ? 'null' : String(value)) + } + }); + + const stringifiedParams = normalizedParams.toString(); + + return stringifiedParams.length > 0 ? `/api/v1/catalog/staff/users?${stringifiedParams}` : `/api/v1/catalog/staff/users` +} + +/** + * @summary Find accounts by email or name, to choose a claim link's recipient + */ +export const staffFindUsers = async (params?: StaffFindUsersParams, options?: Parameters[1]): Promise => { + + return customFetch(getStaffFindUsersUrl(params), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getStaffFindUsersQueryKey = (params?: StaffFindUsersParams,) => { + return [ + `/api/v1/catalog/staff/users`, ...(params ? [params] : []) + ] as const; + } + + +export const getStaffFindUsersQueryOptions = >, TError = unknown>(params?: StaffFindUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getStaffFindUsersQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => staffFindUsers(params, { signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type StaffFindUsersQueryResult = NonNullable>> +export type StaffFindUsersQueryError = unknown + + +export function useStaffFindUsers>, TError = unknown>( + params: undefined | StaffFindUsersParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useStaffFindUsers>, TError = unknown>( + params?: StaffFindUsersParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useStaffFindUsers>, TError = unknown>( + params?: StaffFindUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary Find accounts by email or name, to choose a claim link's recipient + */ + +export function useStaffFindUsers>, TError = unknown>( + params?: StaffFindUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getStaffFindUsersQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return withQueryKey(query, queryOptions.queryKey); +} + + + + + + +export const getStaffCreateClaimInviteUrl = (slug: string,) => { diff --git a/frontend/src/lib/api/generated/model/index.ts b/frontend/src/lib/api/generated/model/index.ts index 8c107c5..8490a7e 100644 --- a/frontend/src/lib/api/generated/model/index.ts +++ b/frontend/src/lib/api/generated/model/index.ts @@ -97,6 +97,7 @@ export * from './staffCommentOut'; export * from './staffCommentPageOut'; export * from './staffCommentReviewIn'; export * from './staffFacetOut'; +export * from './staffFindUsersParams'; export * from './staffLimitOut'; export * from './staffListCommentsParams'; export * from './staffPlanCreateIn'; @@ -121,6 +122,7 @@ export * from './staffToolReviewOut'; export * from './staffToolUpdateOut'; export * from './staffUploadScreenshotBody'; export * from './staffUploadToolLogoBody'; +export * from './staffUserOut'; export * from './submissionIn'; export * from './submissionInOrigin'; export * from './submissionInSurface'; diff --git a/frontend/src/lib/api/generated/model/staffClaimInviteOut.ts b/frontend/src/lib/api/generated/model/staffClaimInviteOut.ts index fcf4a36..684f8ae 100644 --- a/frontend/src/lib/api/generated/model/staffClaimInviteOut.ts +++ b/frontend/src/lib/api/generated/model/staffClaimInviteOut.ts @@ -14,5 +14,7 @@ export interface StaffClaimInviteOut { tool: string; email: string; url: string; + /** False when the mail failed: staff send the link by hand. */ + emailed: boolean; created_at: string; } diff --git a/frontend/src/lib/api/generated/model/staffFindUsersParams.ts b/frontend/src/lib/api/generated/model/staffFindUsersParams.ts new file mode 100644 index 0000000..a62aec0 --- /dev/null +++ b/frontend/src/lib/api/generated/model/staffFindUsersParams.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * redaction-tools API + * Redaction tools compared by price, media and method, with the source and date recorded for every figure. + * OpenAPI spec version: 1.0.0 + */ + +export type StaffFindUsersParams = { +q?: string; +}; diff --git a/frontend/src/lib/api/generated/model/staffUserOut.ts b/frontend/src/lib/api/generated/model/staffUserOut.ts new file mode 100644 index 0000000..9a76421 --- /dev/null +++ b/frontend/src/lib/api/generated/model/staffUserOut.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval 🍺 + * Do not edit manually. + * redaction-tools API + * Redaction tools compared by price, media and method, with the source and date recorded for every figure. + * OpenAPI spec version: 1.0.0 + */ + +export interface StaffUserOut { + id: string; + email: string; + name: string; +}