diff --git a/web/src/lib/api/emails.test.ts b/web/src/lib/api/emails.test.ts new file mode 100644 index 00000000..584e469d --- /dev/null +++ b/web/src/lib/api/emails.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi, type Mock } from "vitest"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { + addEmail, + deleteEmail, + listEmails, + markPrimary, + removeEmailRow, + setPrimaryEmail, + verifyEmailByToken, + type ListEmailItem +} from "./emails"; +import { createDeliberiClient } from "./deliberi"; + +vi.mock("$lib/auth/agent", () => ({ + mintServiceAuth: vi.fn(async () => "test-token"), + serviceDidForHost: (host: string) => `did:web:${host}` +})); + +const row = (overrides: Partial = {}): ListEmailItem => ({ + address: "alice@example.com", + verified: true, + primary: false, + createdAt: "2026-01-01T00:00:00.000Z", + ...overrides +}); + +const makeCtx = (fetchMock: typeof globalThis.fetch) => + createDeliberiClient({ + deliberiUrl: "https://deliberi.test", + agent: {} as unknown as OAuthUserAgent, + fetch: fetchMock + }); + +const calledInit = (mock: Mock) => mock.mock.calls[0][1] as RequestInit; + +const jsonResponse = (body: unknown): Response => + new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" } + }); + +describe("listEmails", () => { + it("gets the listEmails nsid and parses the email shape", async () => { + const item = row({ address: "bob@example.com", verified: false, primary: true }); + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ emails: [item] })); + const out = await listEmails(makeCtx(fetchMock)); + expect(new URL(String(fetchMock.mock.calls[0][0])).pathname).toBe( + "/xrpc/org.tangled.temp.account.listEmails" + ); + expect(out.emails).toEqual([item]); + }); +}); + +describe.each([ + ["addEmail", "addEmail", { email: "a@b.co" }], + ["deleteEmail", "deleteEmail", { email: "a@b.co" }], + ["setPrimaryEmail", "setPrimaryEmail", { email: "a@b.co" }] +] as const)("%s", (_name, nsid, body) => { + it(`posts ${JSON.stringify(body)} to org.tangled.temp.account.${nsid}`, async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("", { status: 200 })); + const ctx = makeCtx(fetchMock); + + if (nsid === "addEmail") await addEmail(ctx, body.email); + else if (nsid === "deleteEmail") await deleteEmail(ctx, body.email); + else await setPrimaryEmail(ctx, body.email); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toBe(`/xrpc/org.tangled.temp.account.${nsid}`); + expect(url.origin).toBe("https://deliberi.test"); + expect(calledInit(fetchMock).method).toBe("POST"); + expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual(body); + }); +}); + +describe("verifyEmailByToken", () => { + it("posts the token to the open verifyEmail endpoint without auth", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(jsonResponse({ email: "alice@example.com" })); + const out = await verifyEmailByToken("https://deliberi.test", "abc123", fetchMock); + expect(out.email).toBe("alice@example.com"); + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = new URL(String(fetchMock.mock.calls[0][0])); + expect(url.pathname).toBe("/xrpc/org.tangled.temp.account.verifyEmail"); + expect(calledInit(fetchMock).method).toBe("POST"); + expect(JSON.parse(String(calledInit(fetchMock).body))).toEqual({ token: "abc123" }); + expect(calledInit(fetchMock).headers).not.toHaveProperty("authorization"); + }); + + it("classifies a 400 as an invalid link", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("nope", { status: 400 })); + await expect( + verifyEmailByToken("https://deliberi.test", "bad", fetchMock) + ).rejects.toMatchObject({ kind: "invalid" }); + }); + + it("classifies a 5xx as an unavailable service", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response("boom", { status: 500 })); + await expect( + verifyEmailByToken("https://deliberi.test", "bad", fetchMock) + ).rejects.toMatchObject({ kind: "unavailable" }); + }); +}); + +describe("markPrimary", () => { + it("moves the flag onto the target and clears the rest", () => { + const rows = [row({ address: "a@x.co", primary: true }), row({ address: "b@x.co" })]; + expect(markPrimary(rows, "b@x.co")).toEqual([ + expect.objectContaining({ address: "a@x.co", primary: false }), + expect.objectContaining({ address: "b@x.co", primary: true }) + ]); + }); +}); + +describe("removeEmailRow", () => { + it("drops the deleted address and keeps every other row", () => { + const rows = [row({ address: "a@x.co" }), row({ address: "b@x.co" })]; + expect(removeEmailRow(rows, "a@x.co").map((r) => r.address)).toEqual(["b@x.co"]); + }); +}); diff --git a/web/src/lib/api/emails.ts b/web/src/lib/api/emails.ts new file mode 100644 index 00000000..8991a91d --- /dev/null +++ b/web/src/lib/api/emails.ts @@ -0,0 +1,68 @@ +import { authedGet, authedPost, type DeliberiContext } from "./deliberi"; +import { buildUrl } from "./_request"; + +export interface ListEmailItem { + address: string; + verified: boolean; + primary: boolean; + createdAt: string; +} + +const LIST_EMAILS = "org.tangled.temp.account.listEmails"; +const ADD_EMAIL = "org.tangled.temp.account.addEmail"; +const VERIFY_EMAIL = "org.tangled.temp.account.verifyEmail"; +const DELETE_EMAIL = "org.tangled.temp.account.deleteEmail"; +const SET_PRIMARY_EMAIL = "org.tangled.temp.account.setPrimaryEmail"; + +export const listEmails = (ctx: DeliberiContext): Promise<{ emails: ListEmailItem[] }> => + authedGet(ctx, LIST_EMAILS); + +export const addEmail = (ctx: DeliberiContext, email: string): Promise => + authedPost(ctx, ADD_EMAIL, { email }).then(() => undefined); + +export class VerificationError extends Error { + constructor( + message: string, + readonly kind: "invalid" | "unavailable" + ) { + super(message); + } +} + +export const verifyEmailByToken = async ( + serviceUrl: string, + token: string, + fetchFn: typeof globalThis.fetch = fetch +): Promise<{ email: string }> => { + const response = await fetchFn(buildUrl(serviceUrl, VERIFY_EMAIL), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ token }) + }); + if (response.status === 400) { + throw new VerificationError( + "The verification link is invalid or has already been used.", + "invalid" + ); + } + if (!response.ok) { + throw new VerificationError( + `Could not reach the verification service (${response.status}).`, + "unavailable" + ); + } + const text = await response.text(); + return text ? JSON.parse(text) : { email: "" }; +}; + +export const deleteEmail = (ctx: DeliberiContext, email: string): Promise => + authedPost(ctx, DELETE_EMAIL, { email }).then(() => undefined); + +export const setPrimaryEmail = (ctx: DeliberiContext, email: string): Promise => + authedPost(ctx, SET_PRIMARY_EMAIL, { email }).then(() => undefined); + +export const markPrimary = (rows: ListEmailItem[], address: string): ListEmailItem[] => + rows.map((row) => ({ ...row, primary: row.address === address })); + +export const removeEmailRow = (rows: ListEmailItem[], address: string): ListEmailItem[] => + rows.filter((row) => row.address !== address); diff --git a/web/src/lib/components/settings/tabs/EmailsTab.svelte b/web/src/lib/components/settings/tabs/EmailsTab.svelte index 57e927c2..bc91ac88 100644 --- a/web/src/lib/components/settings/tabs/EmailsTab.svelte +++ b/web/src/lib/components/settings/tabs/EmailsTab.svelte @@ -1,50 +1,83 @@ - {#if emails.length === 0} + + + {#if load.loading} + + {#each [0, 1, 2] as _, i (i)} +
+
+ + +
+
+ + +
+
+ {/each} +
+ {:else if !load.error && emails.length === 0} - {:else} + {:else if !load.error} - {#each emails as email (email.id)} + {#each emails as email (email.address)} {#snippet tags()} {#if email.verified} Verified + {:else} + Unverified {/if} {#if email.primary} Primary @@ -75,21 +127,46 @@ {/snippet} {#snippet meta()} {email.added}added {relativeTime(email.createdAt)} {/snippet} {#snippet actions()} + {#if email.verified && !email.primary} + + {/if} + {#if !email.verified} + + {/if} {#if !email.primary} - deleteOne.run(email.address)} > + Delete + {/if} - {/snippet} {/each} {/if} + + + {#if resendNotice} +

{resendNotice}

+ {/if} diff --git a/web/src/lib/oauth-client-metadata.json b/web/src/lib/oauth-client-metadata.json index 3f489fac..e6f6ec3b 100644 --- a/web/src/lib/oauth-client-metadata.json +++ b/web/src/lib/oauth-client-metadata.json @@ -5,7 +5,7 @@ "redirect_uris": [ "https://tangled.org/oauth/callback" ], - "scope": "atproto repo:sh.tangled.actor.profile repo:org.tangled.feed.subscription repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.graph.listNetworkVouches?aud=* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.notification.getUnreadCount?aud=* rpc:org.tangled.temp.notification.listNotifications?aud=* rpc:org.tangled.temp.notification.markAllRead?aud=* rpc:org.tangled.temp.notification.markEntityRead?aud=* rpc:org.tangled.temp.notification.updateSeen?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:org.tangled.temp.search.searchCode?aud=* rpc:sh.tangled.git.keepCommit?aud=* rpc:sh.tangled.git.mergeCommit?aud=* rpc:com.atproto.moderation.createReport?aud=*", + "scope": "atproto repo:sh.tangled.actor.profile repo:org.tangled.feed.subscription repo:sh.tangled.feed.comment repo:sh.tangled.feed.reaction repo:sh.tangled.feed.star repo:sh.tangled.graph.follow repo:sh.tangled.graph.vouch repo:sh.tangled.knot repo:sh.tangled.knot.member repo:sh.tangled.label.definition repo:sh.tangled.label.op repo:sh.tangled.publicKey repo:sh.tangled.repo repo:sh.tangled.repo.artifact repo:sh.tangled.repo.collaborator repo:sh.tangled.repo.issue repo:sh.tangled.repo.issue.comment repo:sh.tangled.repo.issue.state repo:sh.tangled.repo.pull repo:sh.tangled.repo.pull.comment repo:sh.tangled.repo.pull.status repo:sh.tangled.spindle repo:sh.tangled.spindle.member repo:sh.tangled.string blob:*/* rpc:sh.tangled.graph.listNetworkVouches?aud=* rpc:sh.tangled.knot.addMember?aud=* rpc:sh.tangled.knot.removeMember?aud=* rpc:sh.tangled.ci.triggerPipeline?aud=* rpc:sh.tangled.ci.cancelPipeline?aud=* rpc:sh.tangled.repo.addCollaborator?aud=* rpc:sh.tangled.repo.addSecret?aud=* rpc:sh.tangled.repo.create?aud=* rpc:sh.tangled.repo.delete?aud=* rpc:sh.tangled.repo.deleteBranch?aud=* rpc:sh.tangled.repo.forkStatus?aud=* rpc:sh.tangled.repo.forkSync?aud=* rpc:sh.tangled.repo.hiddenRef?aud=* rpc:sh.tangled.repo.listSecrets?aud=* rpc:sh.tangled.repo.merge?aud=* rpc:sh.tangled.repo.mergeCheck?aud=* rpc:sh.tangled.repo.removeCollaborator?aud=* rpc:sh.tangled.repo.removeSecret?aud=* rpc:sh.tangled.repo.setDefaultBranch?aud=* rpc:org.tangled.temp.notification.getPreferences?aud=* rpc:org.tangled.temp.notification.updatePreferences?aud=* rpc:org.tangled.temp.notification.getUnreadCount?aud=* rpc:org.tangled.temp.notification.listNotifications?aud=* rpc:org.tangled.temp.notification.markAllRead?aud=* rpc:org.tangled.temp.notification.markEntityRead?aud=* rpc:org.tangled.temp.notification.updateSeen?aud=* rpc:org.tangled.temp.account.listEmails?aud=* rpc:org.tangled.temp.account.deleteEmail?aud=* rpc:org.tangled.temp.account.setPrimaryEmail?aud=* rpc:org.tangled.temp.account.addEmail?aud=* rpc:org.tangled.temp.account.verifyEmail?aud=* rpc:org.tangled.temp.site.getDomainClaim?aud=* rpc:org.tangled.temp.site.claimDomain?aud=* rpc:org.tangled.temp.site.releaseDomain?aud=* rpc:org.tangled.temp.search.searchCode?aud=* rpc:sh.tangled.git.keepCommit?aud=* rpc:sh.tangled.git.mergeCommit?aud=* rpc:com.atproto.moderation.createReport?aud=*", "grant_types": [ "authorization_code", "refresh_token" diff --git a/web/src/routes/settings/emails/new/+page.svelte b/web/src/routes/settings/emails/new/+page.svelte index a23b0485..7a708036 100644 --- a/web/src/routes/settings/emails/new/+page.svelte +++ b/web/src/routes/settings/emails/new/+page.svelte @@ -1,20 +1,35 @@ - - - - - + {#if sentTo} + +
+

+ A verification email was sent to {sentTo}. +

+

+ Open the email and click the verification link. +

+
+
+ + + + {:else} + + + + + + + - - - - + + + + + {/if}