mirror of
https://github.com/HeyPuter/puter.git
synced 2026-07-08 08:12:15 +00:00
feat: add whatsapp support? (#3356)
This commit is contained in:
@@ -86,6 +86,21 @@ describe('PreludeClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel sequence Prelude reports', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
okJson({
|
||||
id: 'vrf_1',
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
}),
|
||||
);
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
const res = await client.createVerification('+14155550123');
|
||||
|
||||
expect(res.channels).toEqual(['whatsapp', 'sms']);
|
||||
});
|
||||
|
||||
it('forwards device_id and user_agent signals when supplied', async () => {
|
||||
fetchMock.mockResolvedValue(okJson({ id: 'v', status: 'success' }));
|
||||
const client = makeClient('sk_test');
|
||||
|
||||
@@ -42,6 +42,12 @@ export type PreludeChannel =
|
||||
| 'viber'
|
||||
| 'zalo'
|
||||
| 'telegram';
|
||||
/**
|
||||
* Channels Prelude reports in a verification's `channels` array — the ordered
|
||||
* delivery sequence, first entry first. Superset of `PreludeChannel`: 'silent'
|
||||
* and 'voice' can appear as delivery methods but can't be preferred.
|
||||
*/
|
||||
export type PreludeDeliveryChannel = PreludeChannel | 'silent' | 'voice';
|
||||
/**
|
||||
* Default per-SMS cost ceiling (EUR). Countries whose Prelude SMS rate exceeds
|
||||
* this — or that have no SMS channel — are not offered phone verification. The
|
||||
@@ -117,18 +123,6 @@ export class PreludeClient extends PuterClient {
|
||||
return price.sms <= this.maxSmsCostEur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create (or retry) a verification: Prelude sends an OTP to `target`.
|
||||
* @param target E.164 phone number, e.g. "+14155550123".
|
||||
* @param signals Optional anti-fraud signals. The more we forward, the
|
||||
* better Prelude's fraud model scores the attempt (per their docs IP +
|
||||
* device + platform are the highest-value). We pass what the backend
|
||||
* already has: the request `ip`, the client `device_id` (ThumbmarkJS
|
||||
* hash), and the `user_agent` (Prelude infers platform/model/OS from it).
|
||||
* `dispatch_id` carries the richer browser signals gathered by Prelude's
|
||||
* frontend JS Signals SDK; it's a top-level field in the request, not part
|
||||
* of the `signals` object, so it's pulled out below.
|
||||
*/
|
||||
async createVerification(
|
||||
target: string,
|
||||
signals: {
|
||||
@@ -137,7 +131,11 @@ export class PreludeClient extends PuterClient {
|
||||
user_agent?: string;
|
||||
dispatch_id?: string;
|
||||
} = {},
|
||||
): Promise<{ id?: string; status: PreludeCreateStatus }> {
|
||||
): Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}> {
|
||||
// Match the 6-box code UI (UIWindowPhoneVerificationRequired). Without
|
||||
// code_size Prelude uses the dashboard default (4). preferred_channel
|
||||
// prioritizes RCS (cheaper); Prelude falls back to SMS when unavailable.
|
||||
@@ -171,6 +169,7 @@ export class PreludeClient extends PuterClient {
|
||||
return this.#post('/verification', body) as Promise<{
|
||||
id?: string;
|
||||
status: PreludeCreateStatus;
|
||||
channels?: PreludeDeliveryChannel[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
||||
@@ -2047,6 +2047,38 @@ describe('AuthController.handleSendConfirmPhone validation', () => {
|
||||
user_agent: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the delivery channel Prelude picked so the client can point at the right app', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await withPrelude(
|
||||
stubPrelude({
|
||||
createVerification: vi.fn(async () => ({
|
||||
status: 'success',
|
||||
channels: ['whatsapp', 'sms'],
|
||||
})),
|
||||
}),
|
||||
async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).toMatchObject({ channel: 'whatsapp' });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('omits `channel` when Prelude reports no delivery sequence', async () => {
|
||||
const { actor } = await makeUserAndActor();
|
||||
await withPrelude(stubPrelude(), async () => {
|
||||
const res = makeRes();
|
||||
await controller.handleSendConfirmPhone(
|
||||
makeReq({ phone: '+14155550123' }, { actor }),
|
||||
res,
|
||||
);
|
||||
expect(res.body).not.toHaveProperty('channel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController phone verification — staging & reuse', () => {
|
||||
|
||||
@@ -1358,6 +1358,10 @@ export class AuthController extends PuterController {
|
||||
typeof req.headers['user-agent'] === 'string'
|
||||
? req.headers['user-agent']
|
||||
: undefined;
|
||||
// First entry of Prelude's delivery sequence — where the code actually
|
||||
// went. Returned to the client so it can point the user at the right
|
||||
// app (e.g. "check WhatsApp" instead of "check your texts").
|
||||
let deliveryChannel: string | undefined;
|
||||
try {
|
||||
const result = await this.clients.prelude.createVerification(
|
||||
parsed.e164,
|
||||
@@ -1368,6 +1372,7 @@ export class AuthController extends PuterController {
|
||||
dispatch_id: dispatchId,
|
||||
},
|
||||
);
|
||||
deliveryChannel = result.channels?.[0];
|
||||
// Prelude rejected the attempt as abusive — surface as rate-limit.
|
||||
if (
|
||||
result.status === 'blocked' ||
|
||||
@@ -1422,7 +1427,10 @@ export class AuthController extends PuterController {
|
||||
} catch {
|
||||
// ignore — best-effort velocity signal
|
||||
}
|
||||
res.json(fallbackAvailable ? { card_fallback_available: true } : {});
|
||||
res.json({
|
||||
...fallbackFields,
|
||||
...(deliveryChannel ? { channel: deliveryChannel } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('/confirm-phone', {
|
||||
|
||||
@@ -110,6 +110,7 @@ function UIWindowPhoneVerificationRequired(options) {
|
||||
no_matches: i18n('phone_no_matches'),
|
||||
select_country: i18n('phone_select_country'),
|
||||
code_sent_to: i18n('phone_code_sent_to'),
|
||||
code_sent_whatsapp: i18n('phone_code_sent_whatsapp'),
|
||||
suggested: i18n('phone_suggested'),
|
||||
all_countries: i18n('phone_all_countries'),
|
||||
};
|
||||
@@ -382,7 +383,9 @@ function UIWindowPhoneVerificationRequired(options) {
|
||||
|
||||
// -- Step 2: 6-digit code (hidden until a code is sent) --
|
||||
h += '<form class="phone-step phone-step-2" style="display:none;">';
|
||||
h += `<p class="phone-subtitle">${T.code_sent_to} <strong style="font-weight: 600; color:#3e5362;" class="phone-target"></strong></p>`;
|
||||
// The prefix is swapped per send: WhatsApp wording when the backend
|
||||
// reports the code went out over WhatsApp, SMS wording otherwise.
|
||||
h += `<p class="phone-subtitle"><span class="phone-code-sent-msg">${T.code_sent_to}</span> <strong style="font-weight: 600; color:#3e5362;" class="phone-target"></strong></p>`;
|
||||
h += '<div class="error" role="alert" aria-live="assertive"></div>';
|
||||
h += ` <fieldset name="number-code" style="border: none; padding:0;" data-number-code-form>
|
||||
<input class="digit-input" type="number" min='0' max='9' inputmode="numeric" autocomplete="one-time-code" name='number-code-0' data-number-code-input='0' required />
|
||||
@@ -743,9 +746,18 @@ function UIWindowPhoneVerificationRequired(options) {
|
||||
contentType: 'application/json',
|
||||
headers: { Authorization: `Bearer ${window.auth_token}` },
|
||||
statusCode: { 401: (xhr) => window.handle401(xhr) },
|
||||
success: function () {
|
||||
success: function (res) {
|
||||
// Advance to the code-entry step with a clean slate.
|
||||
$(el_window).find('.phone-target').text(phone);
|
||||
// `channel` is where Prelude actually delivered the code;
|
||||
// point the user at WhatsApp when it wasn't a plain text.
|
||||
$(el_window)
|
||||
.find('.phone-code-sent-msg')
|
||||
.text(
|
||||
res?.channel === 'whatsapp'
|
||||
? T.code_sent_whatsapp
|
||||
: T.code_sent_to,
|
||||
);
|
||||
numberCodeInputs.forEach((i) => {
|
||||
i.value = '';
|
||||
i.disabled = false;
|
||||
|
||||
@@ -250,6 +250,7 @@ const en = {
|
||||
phone_all_countries: 'All countries',
|
||||
phone_select_country: 'Select country',
|
||||
phone_code_sent_to: 'Enter the 6-digit code sent to',
|
||||
phone_code_sent_whatsapp: 'Enter the 6-digit code sent via WhatsApp to',
|
||||
pick_name_for_website: 'Pick a name for your website:',
|
||||
pick_name_for_worker: 'Pick a name for your worker:',
|
||||
picture: 'Picture',
|
||||
|
||||
Reference in New Issue
Block a user