diff --git a/README.md b/README.md index ec96c06..bcc4d89 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,20 @@ The exchange uses a form-encoded `POST` and never sends a configured bearer toke failures are exposed as `OAuthAuthorizationCodeExchangeError`; client secrets and codes are not included in validation errors. +Password-reset requests can provide the reset page that the backend should place in the email. +Existing callers can continue to pass only the email address: + +```ts +await sdk.auth.forgotPassword("user@example.com"); + +await sdk.auth.forgotPassword("user@example.com", { + resetUrl: "https://auth.put.io/reset-password?next=%2Ffiles", +}); +``` + +The backend validates this URL before using it and falls back to its default reset page when the +option is omitted. + ## Utilities Shared formatting, URL, and error-localization helpers are available from the utilities subpath: diff --git a/src/__snapshots__/index.spec.ts.snap b/src/__snapshots__/index.spec.ts.snap index 1aa3288..0e64bdc 100644 --- a/src/__snapshots__/index.spec.ts.snap +++ b/src/__snapshots__/index.spec.ts.snap @@ -97,6 +97,7 @@ exports[`sdk root entry > exports the expected top-level public surface 1`] = ` "FilesSearchQuerySchema", "FolderTypeSchema", "ForgotPasswordErrorSpec", + "ForgotPasswordOptionsSchema", "FriendBaseSchema", "FriendInviteJoinedUserSchema", "FriendInviteJoinedUserStatusSchema", diff --git a/src/core/client.promise.spec.ts b/src/core/client.promise.spec.ts index 9e200a9..5642598 100644 --- a/src/core/client.promise.spec.ts +++ b/src/core/client.promise.spec.ts @@ -23,7 +23,7 @@ vi.mock("../domains/auth.js", async () => { clients: vi.fn(() => Effect.succeed([{ id: 1, name: "cli" }])), exchangeOAuthAuthorizationCode: vi.fn((input) => Effect.succeed(`exchange:${input.code}`)), exists: vi.fn((key, value) => Effect.succeed(key === "username" && value === "sdk-user")), - forgotPassword: vi.fn((mail) => Effect.succeed({ mail, status: "OK" })), + forgotPassword: vi.fn((mail, options) => Effect.succeed({ mail, options, status: "OK" })), generateTOTP: vi.fn(() => Effect.succeed({ secret: "secret", @@ -547,6 +547,16 @@ describe("sdk promise client adapters", () => { expect(await client.auth.exists("username", "sdk-user")).toBe(true); expect(await client.auth.forgotPassword("a@put.io")).toEqual({ mail: "a@put.io", + options: undefined, + status: "OK", + }); + expect( + await client.auth.forgotPassword("a@put.io", { + resetUrl: "https://auth.put.io/reset-password?next=%2Ffiles", + }), + ).toEqual({ + mail: "a@put.io", + options: { resetUrl: "https://auth.put.io/reset-password?next=%2Ffiles" }, status: "OK", }); expect(await client.auth.getCode({ appId: 8993 })).toMatchObject({ code: "CODE-8993" }); diff --git a/src/domains/auth.spec.ts b/src/domains/auth.spec.ts index 0ea4e02..69b40e4 100644 --- a/src/domains/auth.spec.ts +++ b/src/domains/auth.spec.ts @@ -233,11 +233,29 @@ describe("auth domain", () => { expect( await runSdkEffect(forgotPassword("sdk@put.io"), (request) => { - expect(getFormBody(request).get("mail")).toBe("sdk@put.io"); + const body = getFormBody(request); + expect(body.get("mail")).toBe("sdk@put.io"); + expect(body.has("reset_url")).toBe(false); return jsonResponse({ status: "OK" }); }), ).toEqual({ status: "OK" }); + expect( + await runSdkEffect( + forgotPassword("sdk@put.io", { + resetUrl: "https://auth-staging.put.io/reset-password?next=%2Ffiles%2F99", + }), + (request) => { + const body = getFormBody(request); + expect(body.get("mail")).toBe("sdk@put.io"); + expect(body.get("reset_url")).toBe( + "https://auth-staging.put.io/reset-password?next=%2Ffiles%2F99", + ); + return jsonResponse({ status: "OK" }); + }, + ), + ).toEqual({ status: "OK" }); + expect( await runSdkEffect(resetPassword("key-1", "secret"), (request) => { const body = getFormBody(request); @@ -457,6 +475,15 @@ describe("auth domain", () => { runSdkExit(getFamilyInvite(""), handler), runSdkExit(getFriendInvite(""), handler), runSdkExit(forgotPassword(""), handler), + runSdkExit(forgotPassword("sdk@put.io", { resetUrl: "" }), handler), + runSdkExit( + forgotPassword("sdk@put.io", { + resetUrl: "https://auth.put.io/reset-password", + // @ts-expect-error JavaScript callers can provide unknown request properties. + unexpected: true, + }), + handler, + ), runSdkExit(resetPassword("", sensitivePassword), handler), runSdkExit(getCode({ appId: 0 }), handler), runSdkExit(checkCodeMatch(""), handler), diff --git a/src/domains/auth.ts b/src/domains/auth.ts index 14440ca..374b52a 100644 --- a/src/domains/auth.ts +++ b/src/domains/auth.ts @@ -184,6 +184,13 @@ const AuthResetPasswordInputSchema = Schema.Struct({ key: NonEmptyStringSchema, password: NonEmptyStringSchema, }); +export const ForgotPasswordOptionsSchema = Schema.Struct({ + resetUrl: Schema.optional(NonEmptyStringSchema), +}); +const AuthForgotPasswordInputSchema = Schema.Struct({ + mail: NonEmptyStringSchema, + options: Schema.optional(ForgotPasswordOptionsSchema), +}); const AuthGetCodeInputSchema = Schema.Struct({ appId: AuthClientIdSchema, clientName: Schema.optional(NonEmptyStringSchema), @@ -200,6 +207,7 @@ export type VerifyTOTPResponse = Schema.Schema.Type; export type LoginInput = Schema.Schema.Type; export type AuthGetCodeInput = Schema.Schema.Type; +export type ForgotPasswordOptions = Schema.Schema.Type; export type OAuthAuthorizationCodeExchangeInput = Schema.Schema.Type< typeof OAuthAuthorizationCodeExchangeInputSchema >; @@ -566,12 +574,13 @@ export const getFriendInvite = ( ).pipe(withOperationErrors(FriendInviteLookupErrorSpec)); export const forgotPassword = ( mail: string, + options?: ForgotPasswordOptions, ): Effect.Effect< Schema.Schema.Type, ForgotPasswordError, PutioSdkContext > => - decodeAuthInput("forgotPassword", NonEmptyStringSchema, mail, (decodedMail) => + decodeAuthInput("forgotPassword", AuthForgotPasswordInputSchema, { mail, options }, (input) => requestJson(OkResponseSchema, { auth: { type: "none", @@ -579,7 +588,8 @@ export const forgotPassword = ( body: { type: "form", value: { - mail: decodedMail, + mail: input.mail, + ...(input.options?.resetUrl === undefined ? {} : { reset_url: input.options.resetUrl }), }, }, method: "POST",