Merge branch 'notify-api-sms' into notify-api_endpoint-query
This commit is contained in:
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Commit Message Hook
|
||||
# Validates commit message format using commitlint
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run commitlint but don't fail the commit (|| true)
|
||||
# This provides helpful feedback without blocking commits
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Pre-commit Hook
|
||||
# Runs lint-fix and Build Architecture Guard on staged files
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Running pre-commit hooks..."
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Husky Pre-push Hook
|
||||
# Runs Build Architecture Guard to check commits being pushed
|
||||
#
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
echo "🔍 Running Build Architecture Guard (pre-push)..."
|
||||
|
||||
|
||||
+1
-2
@@ -8,8 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [?] - 2026
|
||||
### Added
|
||||
- Full flow for setting up SMS notifications
|
||||
- SMS notifications talk to the notify-api: phone registration and code
|
||||
- Full flow for setting up SMS notifications: phone registration and code
|
||||
verification, delegated alertSearch JWT batches with a send hour, and
|
||||
revocation to stop the texts
|
||||
- Push-channel client for the notify-api's alert authorization
|
||||
|
||||
@@ -43,6 +43,15 @@ Assumes Xcode and Xcode Command Line Tools are installed.
|
||||
|
||||
See [BUILDING.md](BUILDING.md) for comprehensive build instructions for all platforms (Web, Electron, iOS, Android, Docker).
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
npm run test:web
|
||||
|
||||
# ... or do every check:
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
## 🛡️ Build Architecture Guard
|
||||
|
||||
This project uses **Husky Git hooks** to protect the build system
|
||||
|
||||
Generated
+113
-1
@@ -153,7 +153,8 @@
|
||||
"ts-jest": "^29.4.0",
|
||||
"tsx": "^4.20.4",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@adraffy/ens-normalize": {
|
||||
@@ -11605,6 +11606,35 @@
|
||||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/language-core": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
|
||||
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/source-map": "2.4.28"
|
||||
}
|
||||
},
|
||||
"node_modules/@volar/source-map": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
|
||||
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@volar/typescript": {
|
||||
"version": "2.4.28",
|
||||
"resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
|
||||
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "2.4.28",
|
||||
"path-browserify": "^1.0.1",
|
||||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue-leaflet/vue-leaflet": {
|
||||
"version": "0.10.1",
|
||||
"license": "MIT",
|
||||
@@ -11663,6 +11693,17 @@
|
||||
"@vue/shared": "3.5.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-vue2": {
|
||||
"version": "2.7.16",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz",
|
||||
"integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"de-indent": "^1.0.2",
|
||||
"he": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.6.4",
|
||||
"license": "MIT"
|
||||
@@ -11890,6 +11931,31 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/language-core": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.1.10.tgz",
|
||||
"integrity": "sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/language-core": "~2.4.8",
|
||||
"@vue/compiler-dom": "^3.5.0",
|
||||
"@vue/compiler-vue2": "^2.7.16",
|
||||
"@vue/shared": "^3.5.0",
|
||||
"alien-signals": "^0.2.0",
|
||||
"minimatch": "^9.0.3",
|
||||
"muggle-string": "^0.4.1",
|
||||
"path-browserify": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.5.13",
|
||||
"license": "MIT",
|
||||
@@ -12155,6 +12221,13 @@
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.2.2.tgz",
|
||||
"integrity": "sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/anser": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz",
|
||||
@@ -15004,6 +15077,13 @@
|
||||
"version": "1.11.13",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/de-indent": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||
"integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
"license": "MIT",
|
||||
@@ -23436,6 +23516,13 @@
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/muggle-string": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
|
||||
"integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multibase": {
|
||||
"version": "4.0.6",
|
||||
"license": "MIT",
|
||||
@@ -30292,6 +30379,13 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz",
|
||||
"integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.5.13",
|
||||
"license": "MIT",
|
||||
@@ -30433,6 +30527,24 @@
|
||||
"vue": "^3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-tsc": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.1.10.tgz",
|
||||
"integrity": "sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@volar/typescript": "~2.4.8",
|
||||
"@vue/language-core": "2.1.10",
|
||||
"semver": "^7.5.4"
|
||||
},
|
||||
"bin": {
|
||||
"vue-tsc": "bin/vue-tsc.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"devOptional": true,
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@
|
||||
"lint-fix": "eslint --ext .js,.ts,.vue --ignore-path .gitignore --fix src",
|
||||
"type-safety-check": "./scripts/type-safety-check.sh",
|
||||
"type-check": "tsc --noEmit",
|
||||
"type-check:vue": "vue-tsc --noEmit",
|
||||
"prebuild": "eslint --ext .js,.ts,.vue --ignore-path .gitignore src && node sw_combine.js && node scripts/copy-wasm.js",
|
||||
"test:prerequisites": "node scripts/check-prerequisites.js",
|
||||
"check:dependencies": "./scripts/check-dependencies.sh",
|
||||
@@ -282,6 +283,7 @@
|
||||
"ts-jest": "^29.4.0",
|
||||
"tsx": "^4.20.4",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
"vite": "^5.2.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<p :class="textClasses">{{ text }}</p>
|
||||
|
||||
<button
|
||||
v-if="option1Text"
|
||||
:class="option1ButtonClasses"
|
||||
@click="handleOption1(close)"
|
||||
>
|
||||
@@ -32,6 +33,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="option2Text"
|
||||
:class="option2ButtonClasses"
|
||||
@click="handleOption2(close)"
|
||||
>
|
||||
@@ -39,6 +41,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="option3Text"
|
||||
:class="option3ButtonClasses"
|
||||
@click="handleOption3(close)"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<div v-if="visible" class="dialog-overlay">
|
||||
<div class="dialog">
|
||||
<!-- Step 1: enter phone number -->
|
||||
<div v-if="step === 'phone'">
|
||||
<h1 class="text-xl font-bold text-center mb-2">Verify Your Phone</h1>
|
||||
<p class="text-sm text-slate-500 mb-4">
|
||||
Enter the mobile number where you want to receive New Activity text
|
||||
messages. We'll send a one-time code to confirm it's yours. Standard
|
||||
message and data rates may apply.
|
||||
</p>
|
||||
|
||||
<label for="sms-phone" class="text-slate-500 text-sm font-bold">
|
||||
Mobile number
|
||||
</label>
|
||||
<input
|
||||
id="sms-phone"
|
||||
v-model="phoneNumber"
|
||||
type="tel"
|
||||
inputmode="tel"
|
||||
autocomplete="tel"
|
||||
placeholder="+1 555 123 4567"
|
||||
class="block w-full rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
|
||||
:disabled="sending"
|
||||
@keyup.enter="sendCode()"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="primaryButtonClasses"
|
||||
:disabled="sending"
|
||||
@click="sendCode()"
|
||||
>
|
||||
<font-awesome v-if="sending" icon="spinner" spin class="mr-1" />
|
||||
{{ sending ? "Sending…" : "Send Code" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cancelButtonClasses"
|
||||
:disabled="sending"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: enter the code -->
|
||||
<div v-else-if="step === 'code'">
|
||||
<h1 class="text-xl font-bold text-center mb-2">Enter the Code</h1>
|
||||
<p class="text-sm text-slate-500 mb-4">
|
||||
We sent a 6-digit code to
|
||||
<b>{{ sentTo }}</b
|
||||
>. Enter it below to finish verifying your number.
|
||||
</p>
|
||||
|
||||
<label for="sms-code" class="text-slate-500 text-sm font-bold">
|
||||
Verification code
|
||||
</label>
|
||||
<input
|
||||
id="sms-code"
|
||||
v-model="code"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="6"
|
||||
placeholder="123456"
|
||||
class="block w-full tracking-[0.4em] text-center text-lg rounded border border-slate-400 mt-1 mb-1 px-3 py-2"
|
||||
:disabled="verifying"
|
||||
@keyup.enter="verifyCode()"
|
||||
/>
|
||||
<p v-if="errorMessage" class="text-sm text-red-600 mb-2">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="text-center mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-blue-500"
|
||||
:disabled="sending || verifying"
|
||||
@click="resendCode()"
|
||||
>
|
||||
{{ sending ? "Resending…" : "Resend code" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="primaryButtonClasses"
|
||||
:disabled="verifying"
|
||||
@click="verifyCode()"
|
||||
>
|
||||
<font-awesome v-if="verifying" icon="spinner" spin class="mr-1" />
|
||||
{{ verifying ? "Verifying…" : "Verify" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cancelButtonClasses"
|
||||
:disabled="verifying"
|
||||
@click="onClickCancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Vue, Component } from "vue-facing-decorator";
|
||||
|
||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||
import { logger } from "@/utils/logger";
|
||||
import {
|
||||
normalizePhoneNumber,
|
||||
registerSmsPhone,
|
||||
SmsApiError,
|
||||
smsErrorMessage,
|
||||
verifySmsPhone,
|
||||
} from "@/services/notifications/smsNotificationApi";
|
||||
|
||||
/**
|
||||
* SmsVerificationDialog Component
|
||||
*
|
||||
* A two-step modal that walks the user through verifying a mobile number so
|
||||
* they can opt in to SMS "New Activity" notifications:
|
||||
* 1. Enter phone number -> `POST /notify-sms/phone` texts a one-time code
|
||||
* 2. Enter the code -> `PUT /notify-sms/phone` confirms possession
|
||||
*
|
||||
* On successful verification the callback fires with (true, phoneNumber), the
|
||||
* number in the E.164 form the service stores, so the caller can then let the
|
||||
* user turn the SMS notification on. Cancelling fires the callback with
|
||||
* (false).
|
||||
*
|
||||
* A number this identity has already verified comes back from the POST with
|
||||
* `verified: true` and no text sent, which finishes the dialog in one step.
|
||||
*/
|
||||
@Component({
|
||||
mixins: [PlatformServiceMixin],
|
||||
})
|
||||
export default class SmsVerificationDialog extends Vue {
|
||||
visible = false;
|
||||
step: "phone" | "code" = "phone";
|
||||
phoneNumber = "";
|
||||
sentTo = "";
|
||||
code = "";
|
||||
sending = false;
|
||||
verifying = false;
|
||||
errorMessage = "";
|
||||
|
||||
/** Identity the registration is scoped to; every route is per-DID. */
|
||||
activeDid = "";
|
||||
|
||||
callback: (success: boolean, phoneNumber?: string) => void = () => {};
|
||||
|
||||
/**
|
||||
* Opens the dialog at the phone-entry step.
|
||||
* @param activeDid - identity the number is registered under
|
||||
* @param aCallback - fired with (true, phoneNumber) on success, (false) on cancel
|
||||
* @param prefillPhone - optional number to pre-populate (e.g. re-verifying)
|
||||
*/
|
||||
open(
|
||||
activeDid: string,
|
||||
aCallback?: (success: boolean, phoneNumber?: string) => void,
|
||||
prefillPhone = "",
|
||||
) {
|
||||
this.activeDid = activeDid;
|
||||
this.callback = aCallback || this.callback;
|
||||
this.step = "phone";
|
||||
this.phoneNumber = prefillPhone;
|
||||
this.sentTo = "";
|
||||
this.code = "";
|
||||
this.sending = false;
|
||||
this.verifying = false;
|
||||
this.errorMessage = "";
|
||||
this.visible = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the phone number and ask the service to text a code.
|
||||
*
|
||||
* The masked number the service echoes back is what the user sees; the
|
||||
* normalized one is what later calls send, since the mask is not a number.
|
||||
*/
|
||||
async sendCode(): Promise<void> {
|
||||
this.errorMessage = "";
|
||||
const normalized = normalizePhoneNumber(this.phoneNumber);
|
||||
if (!normalized || normalized.replace(/\D/g, "").length < 7) {
|
||||
this.errorMessage = "Please enter a valid mobile number.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.sending = true;
|
||||
try {
|
||||
const result = await registerSmsPhone(this.activeDid, normalized);
|
||||
this.phoneNumber = normalized;
|
||||
|
||||
if (result.verified) {
|
||||
// Already verified under this identity: nothing was texted and there
|
||||
// is no code to enter.
|
||||
this.visible = false;
|
||||
this.callback(true, normalized);
|
||||
return;
|
||||
}
|
||||
|
||||
this.sentTo = result.phoneNumber || normalized;
|
||||
// Only a dev server with SMS_DEV_ECHO_CODE returns this; it saves a
|
||||
// developer with no carrier coverage from a dead-end flow.
|
||||
this.code = result.devCode || "";
|
||||
this.step = "code";
|
||||
} catch (error) {
|
||||
logger.error("[SmsVerificationDialog] sendCode failed:", error);
|
||||
this.errorMessage = smsErrorMessage(
|
||||
error,
|
||||
"Could not send the code. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the service to send a fresh code to the same number. Sends are
|
||||
* throttled to three per number per hour, so this can be refused.
|
||||
*/
|
||||
async resendCode(): Promise<void> {
|
||||
this.errorMessage = "";
|
||||
this.sending = true;
|
||||
try {
|
||||
const result = await registerSmsPhone(this.activeDid, this.phoneNumber);
|
||||
this.sentTo = result.phoneNumber || this.sentTo;
|
||||
// A number already verified comes back with no code, dev or otherwise.
|
||||
this.code = result.verified ? "" : result.devCode || "";
|
||||
} catch (error) {
|
||||
logger.error("[SmsVerificationDialog] resendCode failed:", error);
|
||||
this.errorMessage = smsErrorMessage(
|
||||
error,
|
||||
"Could not resend the code. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the entered code with the service.
|
||||
*
|
||||
* A wrong code says how many tries are left; running out clears the code
|
||||
* server-side, so the only way forward is a fresh send from step one.
|
||||
*/
|
||||
async verifyCode(): Promise<void> {
|
||||
this.errorMessage = "";
|
||||
if (!/^\d{6}$/.test(this.code.trim())) {
|
||||
this.errorMessage = "Enter the 6-digit code we texted you.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.verifying = true;
|
||||
try {
|
||||
await verifySmsPhone(this.activeDid, this.phoneNumber, this.code);
|
||||
this.visible = false;
|
||||
this.callback(true, this.phoneNumber);
|
||||
} catch (error) {
|
||||
logger.error("[SmsVerificationDialog] verifyCode failed:", error);
|
||||
this.errorMessage = smsErrorMessage(
|
||||
error,
|
||||
"Could not verify the code. Please try again.",
|
||||
);
|
||||
if (
|
||||
error instanceof SmsApiError &&
|
||||
error.code === "SMS_CODE_ATTEMPTS_EXHAUSTED"
|
||||
) {
|
||||
// The stored code is gone; recovery is another send, not another guess.
|
||||
this.step = "phone";
|
||||
this.code = "";
|
||||
}
|
||||
} finally {
|
||||
this.verifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
onClickCancel(): void {
|
||||
this.visible = false;
|
||||
this.callback(false);
|
||||
}
|
||||
|
||||
get primaryButtonClasses(): string {
|
||||
return "block w-full text-center text-lg font-bold uppercase bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50";
|
||||
}
|
||||
|
||||
get cancelButtonClasses(): string {
|
||||
return "block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-3 rounded-md disabled:opacity-50";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -59,11 +59,20 @@ export const ACCOUNT_VIEW_CONSTANTS = {
|
||||
// Notification messages
|
||||
NOTIFICATIONS: {
|
||||
NEW_ACTIVITY_INFO: `
|
||||
This will only notify you when there is new relevant activity for you personally.
|
||||
Note that it runs on your device and many factors may affect delivery,
|
||||
so if you want a reliable but simple daily notification then choose a 'Reminder'.
|
||||
This will notify you with an app message when there is new relevant activity for you personally.
|
||||
Do you want more details?
|
||||
`,
|
||||
NEW_ACTIVITY_SMS_INFO: `
|
||||
This is opt-in: by turning it on you agree to receive text messages when there is new relevant activity for you personally.
|
||||
Standard message and data rates may apply, and you can turn it off at any time.
|
||||
Do you want more details?
|
||||
`,
|
||||
SMS_OPT_IN_CHOICE: `
|
||||
This is opt-in: by entering your phone number you agree to receive text messages when there is new relevant activity for you personally.
|
||||
Standard message and data rates may apply, and you can turn it off at any time.
|
||||
`,
|
||||
SMS_FORGET_PHONE_CONFIRM:
|
||||
"This will remove your phone number and turn off all text messages. You will have to verify a number again to re-enable them. Are you sure?",
|
||||
REMINDER_INFO: `
|
||||
This will notify you at a specific time each day.
|
||||
Note that it does not give you personalized notifications,
|
||||
|
||||
@@ -143,24 +143,48 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-4 mb-2">
|
||||
<!-- label -->
|
||||
<div>
|
||||
|
||||
<!--
|
||||
New Activity Notification: two delivery channels for the same alert.
|
||||
Both let the user pick a time; they differ only in how the alert is
|
||||
delivered (and therefore in the parameters passed to the notify-api):
|
||||
- In-App: on-device notification via the daily-notification-plugin.
|
||||
- SMS: text message sent by the notify-api (opt-in).
|
||||
-->
|
||||
<div class="mt-4 mb-1">
|
||||
<div class="font-semibold">
|
||||
New Activity Notification
|
||||
<button
|
||||
class="text-slate-400 fa-fw cursor-pointer"
|
||||
aria-label="Learn more about New Activity notifications"
|
||||
@click.stop="showNewActivityNotificationInfo"
|
||||
@click.stop="showNewActivityNotificationInfo(false)"
|
||||
>
|
||||
<font-awesome icon="question-circle" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500">
|
||||
Get told about new relevant activity for you. Choose a delivery
|
||||
channel below -- you can turn on either one, or both.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Channel 1: In-App notification -->
|
||||
<div class="flex items-center justify-between mt-3 mb-2 pl-1">
|
||||
<!-- label -->
|
||||
<div>
|
||||
<font-awesome
|
||||
icon="comment"
|
||||
class="fa-fw text-slate-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
In-App Notification
|
||||
</div>
|
||||
<!-- toggle -->
|
||||
<div
|
||||
class="relative ml-2 cursor-pointer"
|
||||
role="switch"
|
||||
:aria-checked="notifyingNewActivity"
|
||||
aria-label="Toggle New Activity notifications"
|
||||
aria-label="Toggle New Activity notifications in app"
|
||||
tabindex="0"
|
||||
@click.stop.prevent="showNewActivityNotificationChoice()"
|
||||
>
|
||||
@@ -196,7 +220,7 @@
|
||||
class="w-full text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
|
||||
@click="editNewActivityNotification"
|
||||
>
|
||||
Edit New Activity Notification…
|
||||
Edit In-App Time…
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,6 +340,8 @@
|
||||
</div>
|
||||
</section>
|
||||
<PushNotificationPermission ref="pushNotificationPermission" />
|
||||
<SmsVerificationDialog ref="smsVerificationDialog" />
|
||||
<ChoiceButtonDialog ref="smsOptInChoiceDialog" />
|
||||
|
||||
<!-- User Profile -->
|
||||
<section
|
||||
@@ -958,10 +984,12 @@ import { copyToClipboard } from "../services/ClipboardService";
|
||||
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
|
||||
import ChoiceButtonDialog from "../components/ChoiceButtonDialog.vue";
|
||||
import EntityIcon from "../components/EntityIcon.vue";
|
||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||
import PushNotificationPermission from "../components/PushNotificationPermission.vue";
|
||||
import QuickNav from "../components/QuickNav.vue";
|
||||
import SmsVerificationDialog from "../components/SmsVerificationDialog.vue";
|
||||
import TopMessage from "../components/TopMessage.vue";
|
||||
import UserNameDialog from "../components/UserNameDialog.vue";
|
||||
import DataExportSection from "../components/DataExportSection.vue";
|
||||
@@ -1048,6 +1076,7 @@ interface PushNotificationPermissionRef {
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
ChoiceButtonDialog,
|
||||
EntityIcon,
|
||||
ImageMethodDialog,
|
||||
LMap,
|
||||
@@ -1055,6 +1084,7 @@ interface PushNotificationPermissionRef {
|
||||
LTileLayer,
|
||||
PushNotificationPermission,
|
||||
QuickNav,
|
||||
SmsVerificationDialog,
|
||||
TopMessage,
|
||||
UserNameDialog,
|
||||
DataExportSection,
|
||||
@@ -1114,15 +1144,18 @@ export default class AccountViewView extends Vue {
|
||||
// Notification properties
|
||||
notifyingNewActivity: boolean = false;
|
||||
notifyingNewActivityTime: string = "";
|
||||
|
||||
// Notify settings hold what the service cannot report back: it has a route
|
||||
// to list registered numbers but none to read an authorization, so the
|
||||
// chosen time and the paused/active distinction live here.
|
||||
/** Verified SMS phone number; kept when smsEnabled turns off, cleared by Forget. */
|
||||
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
|
||||
// Settings hold what the service cannot report back: it has a route to list
|
||||
// registered numbers but none to read an authorization, so the chosen time
|
||||
// and the paused/active distinction live here.
|
||||
/** Master switch: the SMS channel is on (requires a verified phone). */
|
||||
smsEnabled: boolean = false;
|
||||
/** Verified phone number; kept when smsEnabled turns off, cleared by Forget. */
|
||||
notifyingNewActivitySmsPhone: string = "";
|
||||
notifyingNewActivitySms: boolean = false;
|
||||
notifyingNewActivitySmsTime: string = "";
|
||||
|
||||
/** Guard: one in-flight `/notify-sms` write at a time. */
|
||||
smsRequestInProgress: boolean = false;
|
||||
notifyingReminder: boolean = false;
|
||||
notifyingReminderMessage: string = "";
|
||||
notifyingReminderTime: string = "";
|
||||
@@ -1130,13 +1163,6 @@ export default class AccountViewView extends Vue {
|
||||
editReminderScheduleInProgress: boolean = false;
|
||||
/** Dev/test only: use 10-minute rollover for daily reminder (plugin rolloverIntervalMinutes). */
|
||||
reminderFastRolloverForTesting: boolean = false;
|
||||
|
||||
// SMS channel (opt-in), backed by the notify-api's `/notify-sms` routes.
|
||||
/** Master switch: the SMS channel is on (requires a verified phone). */
|
||||
smsEnabled: boolean = false;
|
||||
/** Guard: one in-flight `/notify-sms` write at a time. */
|
||||
smsRequestInProgress: boolean = false;
|
||||
|
||||
subscription: PushSubscription | null = null;
|
||||
|
||||
// UI state properties
|
||||
@@ -1479,9 +1505,11 @@ export default class AccountViewView extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async showNewActivityNotificationInfo(): Promise<void> {
|
||||
async showNewActivityNotificationInfo(isSms: boolean): Promise<void> {
|
||||
this.notify.confirm(
|
||||
ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
||||
isSms
|
||||
? ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_SMS_INFO
|
||||
: ACCOUNT_VIEW_CONSTANTS.NOTIFICATIONS.NEW_ACTIVITY_INFO,
|
||||
async () => {
|
||||
await (this.$router as Router).push({
|
||||
name: "help-notification-types",
|
||||
@@ -2315,9 +2343,8 @@ export default class AccountViewView extends Vue {
|
||||
this.notifyingReminder = false;
|
||||
this.notifyingReminderMessage = "";
|
||||
this.notifyingReminderTime = "";
|
||||
|
||||
// The SMS channel is deliberately untouched: this runs when the browser's
|
||||
// web-push subscription has gone missing, but SMS uses other rails.
|
||||
// web-push subscription has gone missing, and texts do not travel over it.
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -39,29 +39,29 @@
|
||||
something, so you can record thanks in here.
|
||||
</p>
|
||||
<p>
|
||||
This is a reliable message, but it doesn't contain any details about
|
||||
This is your own message activated in the app, but it doesn't contain any details about
|
||||
activity that might be especially interesting to you.
|
||||
Note that the timing is not precise: the device may deliver it a bit later than scheduled.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications</h2>
|
||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- In App</h2>
|
||||
<div>
|
||||
<p>
|
||||
The New Activity Notification will be sent to you when there is new, relevant activity
|
||||
for you.
|
||||
The New Activity Notification will be activated in your app when there is new, relevant
|
||||
activity for you.
|
||||
It will only trigger if something involves you or a project of interest; it will not
|
||||
bug you for other, general activity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-4">New Activity Notifications -- Texts From A Server</h2>
|
||||
<div>
|
||||
<p>
|
||||
This type is not as reliable as a Reminder Notification because mobile devices often
|
||||
suppress such notifications to save battery. (If you want to quickly check for relevant
|
||||
activity daily, use the Reminder Notification and open the app and look for a large green
|
||||
button that points out new activity that is personal to you. We are working on other
|
||||
ways to notify you more reliably.
|
||||
<router-link class="text-blue-500" to="/help">
|
||||
go here to follow us or contact us
|
||||
<font-awesome icon="chevron-right" class="fa-fw"></font-awesome>
|
||||
</router-link>.)
|
||||
This New Activity Notification SMS will be sent to you when there is new, relevant
|
||||
activity for you.
|
||||
It will only trigger if something involves you or a project of interest; it will not
|
||||
bug you for other, general activity.
|
||||
</p>
|
||||
<p class="mt-2">Notes and caveats for text messages:</p>
|
||||
<ul class="list-disc list-outside ml-5 mt-1">
|
||||
@@ -108,6 +108,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- eslint-enable -->
|
||||
</section>
|
||||
|
||||
@@ -85,7 +85,8 @@ test('Record an offer', async ({ page }) => {
|
||||
|
||||
// click on the number of new offers to go to the list page
|
||||
await offerNumElem.click();
|
||||
await expect(page.getByText('New Offers To Your Projects', { exact: true })).toBeVisible();
|
||||
// "Offer" for exactly one new offer, "Offers" otherwise
|
||||
await expect(page.getByText(/^New Offers? To Your Projects$/)).toBeVisible();
|
||||
// get the icon child of the showOffersToUserProjects
|
||||
await page.getByTestId('showOffersToUserProjects').locator('div > svg.fa-chevron-right').click();
|
||||
await expect(page.getByText(description)).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user