Tie the in-app New Activity toggle to AlertSearch authorization so turning it on uploads the batch and turning it off deletes it without unregistering the FCM device.

This commit is contained in:
Jose Olarte III
2026-10-08 15:50:55 -04:00
parent b0292d3f04
commit 07cb1feddf
8 changed files with 416 additions and 87 deletions
+32 -17
View File
@@ -386,19 +386,28 @@ export default class App extends Vue {
// On native (iOS/Android) we don't use web push; the callback handles cancel + state in the view.
// The callback is the one passed for this specific modal (New Activity or Daily Reminder), so we only turn off that one.
if (Capacitor.isNativePlatform()) {
// A false callback means the view refused the off (AlertSearch revoke
// failed). Leave the existing "Notifications are off." toast for every
// other callback, which returns void.
let turnedOff = true;
if (notification.callback) {
await notification.callback(true);
const callbackResult = await notification.callback(true);
if (callbackResult === false) {
turnedOff = false;
}
}
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: "Notifications are off.",
},
5000,
);
return true;
if (turnedOff) {
this.$notify(
{
group: "alert",
type: "info",
title: "Finished",
text: "Notifications are off.",
},
5000,
);
}
return turnedOff;
}
let subscription: PushSubscriptionJSON | null = null;
@@ -483,7 +492,17 @@ export default class App extends Vue {
return false;
});
const message = pushServerSuccess
// Ask the view before announcing success. A callback that returns false
// (failed AlertSearch revoke) keeps the existing failure sentence.
// Callbacks that return void, including Daily Reminder, keep today's copy.
let callbackRefused = false;
if (notification.callback) {
const callbackResult = await notification.callback(pushServerSuccess);
callbackRefused = callbackResult === false;
}
const turnedOff = pushServerSuccess && !callbackRefused;
const message = turnedOff
? "Notification is off."
: "Notification is still on. Try to turn it off again.";
@@ -497,11 +516,7 @@ export default class App extends Vue {
5000,
);
if (notification.callback) {
notification.callback(pushServerSuccess);
}
return pushServerSuccess;
return turnedOff;
} catch (error) {
this.$logAndConsole(
"Error turning off notifications: " + JSON.stringify(error),
+1 -1
View File
@@ -79,7 +79,7 @@ export interface NotificationIface {
type: string; // "toast" | "info" | "success" | "warning" | "danger"
title?: string;
text?: string;
callback?: (success: boolean) => Promise<void>; // if this triggered an action
callback?: (success: boolean) => Promise<void | boolean>; // if this triggered an action
noText?: string;
onCancel?: (stopAsking?: boolean) => Promise<void>;
onNo?: (stopAsking?: boolean) => Promise<void>;
@@ -1,9 +1,14 @@
import type { AlertAuthorizationBatch } from "./alertAuthorizationBatch";
import {
type AlertAuthorizationDependencies,
revokeAlertSearchAuthorization,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
import { alertSearchNotifyTimeUtcFromLocalNineAm } from "./alertSearchNotifyTime";
import {
authorizeInAppNewActivity,
revokeInAppNewActivity,
} from "./newActivityInAppSubscription";
jest.mock("@/libs/crypto/vc", () => ({
ETHR_DID_PREFIX: "did:ethr:",
@@ -238,3 +243,141 @@ describe("uploadAlertSearchAuthorization", () => {
});
});
});
function alertAuthorizationUrl(call: unknown[]): string {
return String(call[0]);
}
describe("revokeAlertSearchAuthorization", () => {
it("deletes alert authorization with the upload path's authenticated headers", async () => {
const { dependencies, fetchMock, mintMock, authMock } =
createDependencies();
const result = await revokeAlertSearchAuthorization(dependencies);
expect(result).toEqual({ ok: true, status: 200 });
expect(mintMock).not.toHaveBeenCalled();
expect(authMock).toHaveBeenCalledWith(undefined, ACTIVE_DID);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://notification-backend.invalid/notifications/alert-authorization",
);
expect(init.method).toBe("DELETE");
expect(init.body).toBeUndefined();
expect(init.headers).toEqual({
"Content-Type": "application/json",
Authorization: "Bearer access-token-fixture",
});
});
it("does not unregister the FCM device registration", async () => {
const { dependencies, fetchMock } = createDependencies();
await revokeAlertSearchAuthorization(dependencies);
const urls = fetchMock.mock.calls.map(alertAuthorizationUrl);
expect(urls).toEqual([
"https://notification-backend.invalid/notifications/alert-authorization",
]);
expect(urls.some((url) => url.includes("/notifications/register"))).toBe(
false,
);
expect(fetchMock.mock.calls[0][1].method).not.toBe("POST");
});
it("rejects notification auth bypass before submission", async () => {
const { dependencies, fetchMock, mintMock } = createDependencies({
bypassAuth: true,
});
const result = await revokeAlertSearchAuthorization(dependencies);
expect(result.ok).toBe(false);
expect(mintMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
it("surfaces a coded refusal without treating it as revoked", async () => {
const { dependencies, fetchMock } = createDependencies({
response: createResponse(500, {
success: false,
error: "ALERT_AUTHORIZATION_DELETE_FAILED",
message: "Could not delete AlertSearch authorization.",
jwt: "must-not-be-returned",
}),
});
const result = await revokeAlertSearchAuthorization(dependencies);
expect(result).toEqual({
ok: false,
status: 500,
errorCode: "ALERT_AUTHORIZATION_DELETE_FAILED",
errorMessage: "Could not delete AlertSearch authorization.",
});
expect(JSON.stringify(result)).not.toContain("must-not-be-returned");
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].method).toBe("DELETE");
});
});
describe("in-app New Activity AlertSearch subscription", () => {
it("uploads AlertSearch authorization when the setting is enabled", async () => {
const { dependencies, fetchMock, mintMock } = createDependencies();
const reportError = jest.fn();
const result = await authorizeInAppNewActivity(reportError, dependencies);
expect(result.ok).toBe(true);
expect(mintMock).toHaveBeenCalledWith(ACTIVE_DID);
expect(reportError).not.toHaveBeenCalled();
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(
"https://notification-backend.invalid/notifications/alert-authorization",
);
expect(init.method).toBe("POST");
});
it("deletes alert authorization when the setting is disabled", async () => {
const { dependencies, fetchMock, mintMock } = createDependencies();
const reportError = jest.fn();
const revoked = await revokeInAppNewActivity(reportError, dependencies);
expect(revoked).toBe(true);
expect(mintMock).not.toHaveBeenCalled();
expect(reportError).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].method).toBe("DELETE");
expect(alertAuthorizationUrl(fetchMock.mock.calls[0])).toBe(
"https://notification-backend.invalid/notifications/alert-authorization",
);
expect(
fetchMock.mock.calls.some((call) =>
alertAuthorizationUrl(call).includes("/notifications/register"),
),
).toBe(false);
});
it("reports a failed delete and does not treat it as revoked", async () => {
const { dependencies, fetchMock } = createDependencies({
response: createResponse(500, {
success: false,
message: "Could not delete AlertSearch authorization.",
}),
});
const reportError = jest.fn();
const revoked = await revokeInAppNewActivity(reportError, dependencies);
expect(revoked).toBe(false);
expect(reportError).toHaveBeenCalledTimes(1);
expect(reportError).toHaveBeenCalledWith(
"Could not delete AlertSearch authorization.",
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][1].method).toBe("DELETE");
});
});
+128 -50
View File
@@ -1,8 +1,11 @@
/**
* Manual upload of a push-channel AlertSearch authorization, for the
* notification debug panel: mint a delegated JWT batch for the active identity
* and `POST /notifications/alert-authorization`, with the send time fixed at
* 09:00 in the device's zone.
* Push-channel AlertSearch authorization against notification-wakeup-service.
*
* Upload mints a delegated JWT batch for the active identity and
* `POST /notifications/alert-authorization`, with the send time fixed at
* 09:00 in the device's zone. Revoke sends `DELETE` on that same route and
* does not unregister the FCM device. The debug panel uploads manually; the
* in-app New Activity toggle uploads on enable and revokes on disable.
*
* The SMS channel uploads the same body through `authorizeSmsAlertSearch`, and
* both mint with `mintAlertAuthorizationBatch`. The wire shapes are in
@@ -42,6 +45,19 @@ export type AlertAuthorizationUploadResult =
errorCode?: string;
};
/** `DELETE /notifications/alert-authorization` does not carry a batch. */
export type AlertAuthorizationRevokeResult =
| {
ok: true;
status: number;
}
| {
ok: false;
errorMessage: string;
status?: number;
errorCode?: string;
};
type FetchNotificationApi = (
input: RequestInfo | URL,
init?: RequestInit,
@@ -89,6 +105,53 @@ function buildRequestBody(
};
}
type DelegatedAlertAccess =
| { ok: true; did: string; headers: Record<string, string> }
| { ok: false; errorMessage: string };
/**
* Same gate as upload: an active `did:ethr`, no auth bypass, and authenticated
* notification headers. `action` only changes the bypass sentence.
*/
async function requireDelegatedAlertAccess(
dependencies: AlertAuthorizationDependencies,
action: "uploaded" | "revoked",
): Promise<DelegatedAlertAccess> {
const did = await dependencies.getActiveDid();
if (!did) {
return { ok: false, errorMessage: "No active identity is available." };
}
if (!did.startsWith(ETHR_DID_PREFIX)) {
return {
ok: false,
errorMessage:
"AlertSearch authorization requires an active did:ethr identity.",
};
}
if (dependencies.isAuthBypassEnabled()) {
return {
ok: false,
errorMessage: `AlertSearch authorization cannot be ${action} while JWT authentication is skipped.`,
};
}
const auth = await dependencies.getAuthHeaders(undefined, did);
if (!auth.ok) {
return {
ok: false,
errorMessage: `Authentication unavailable: ${auth.message}`,
};
}
if (!auth.authenticated) {
return {
ok: false,
errorMessage:
"AlertSearch authorization requires authenticated notification API headers.",
};
}
return { ok: true, did, headers: auth.headers };
}
/**
* The code and message of a refusal, and nothing else from its body. The
* service sends an `AlertAuthorizationFailure`: the code, when there is one,
@@ -118,73 +181,53 @@ async function readRefusal(
};
}
async function authorizationHttpFailure(response: Response): Promise<{
ok: false;
status: number;
errorMessage: string;
errorCode?: string;
}> {
const refusal = await readRefusal(response);
const fallback =
response.status === 401 || response.status === 403
? httpAuthErrorMessage(response.status)
: `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
errorMessage: refusal.message || fallback,
...(refusal.errorCode ? { errorCode: refusal.errorCode } : {}),
};
}
/**
* Mint and manually upload a delegated AlertSearch authorization batch.
* Mint and upload a delegated AlertSearch authorization batch.
* This function never retries and refuses notification auth bypass mode.
*/
export async function uploadAlertSearchAuthorization(
dependencies: AlertAuthorizationDependencies = defaultDependencies,
): Promise<AlertAuthorizationUploadResult> {
try {
const did = await dependencies.getActiveDid();
if (!did) {
return { ok: false, errorMessage: "No active identity is available." };
}
if (!did.startsWith(ETHR_DID_PREFIX)) {
return {
ok: false,
errorMessage:
"AlertSearch authorization requires an active did:ethr identity.",
};
}
if (dependencies.isAuthBypassEnabled()) {
return {
ok: false,
errorMessage:
"AlertSearch authorization cannot be uploaded while JWT authentication is skipped.",
};
const access = await requireDelegatedAlertAccess(dependencies, "uploaded");
if (!access.ok) {
return access;
}
const batch = await dependencies.mintBatch(did);
const batch = await dependencies.mintBatch(access.did);
const timeZone = dependencies.getTimeZone();
const body = buildRequestBody(batch, timeZone);
const auth = await dependencies.getAuthHeaders(undefined, did);
if (!auth.ok) {
return {
ok: false,
errorMessage: `Authentication unavailable: ${auth.message}`,
};
}
if (!auth.authenticated) {
return {
ok: false,
errorMessage:
"AlertSearch authorization requires authenticated notification API headers.",
};
}
const response = await dependencies.fetch(
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
{
method: "POST",
headers: auth.headers,
headers: access.headers,
body: JSON.stringify(body),
},
);
if (!response.ok) {
const refusal = await readRefusal(response);
const fallback =
response.status === 401 || response.status === 403
? httpAuthErrorMessage(response.status)
: `HTTP ${response.status}`;
return {
ok: false,
status: response.status,
errorMessage: refusal.message || fallback,
...(refusal.errorCode ? { errorCode: refusal.errorCode } : {}),
};
return authorizationHttpFailure(response);
}
return {
@@ -203,3 +246,38 @@ export async function uploadAlertSearchAuthorization(
};
}
}
/**
* Delete the active identity's push AlertSearch authorization batches.
* Uses the same URL, auth headers, and refusal handling as upload.
* Does not unregister the FCM device. Never retries.
*/
export async function revokeAlertSearchAuthorization(
dependencies: AlertAuthorizationDependencies = defaultDependencies,
): Promise<AlertAuthorizationRevokeResult> {
try {
const access = await requireDelegatedAlertAccess(dependencies, "revoked");
if (!access.ok) {
return access;
}
const response = await dependencies.fetch(
`${dependencies.getBaseUrl()}/notifications/alert-authorization`,
{
method: "DELETE",
headers: access.headers,
},
);
if (!response.ok) {
return authorizationHttpFailure(response);
}
return { ok: true, status: response.status };
} catch (error: unknown) {
return {
ok: false,
errorMessage: error instanceof Error ? error.message : "Revoke failed.",
};
}
}
+12 -2
View File
@@ -38,8 +38,18 @@ export {
export { NotificationService, registerToken } from "./NotificationService";
export { NativeNotificationService } from "./NativeNotificationService";
export { WebPushNotificationService } from "./WebPushNotificationService";
export { uploadAlertSearchAuthorization } from "./alertAuthorization";
export type { AlertAuthorizationUploadResult } from "./alertAuthorization";
export {
revokeAlertSearchAuthorization,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
export type {
AlertAuthorizationRevokeResult,
AlertAuthorizationUploadResult,
} from "./alertAuthorization";
export {
authorizeInAppNewActivity,
revokeInAppNewActivity,
} from "./newActivityInAppSubscription";
export { configureNativeFetcherIfReady } from "./nativeFetcherConfig";
export { runLegacyApiNotificationsCleanupOnce } from "./legacyApiNotificationsCleanup";
@@ -0,0 +1,51 @@
/**
* Server AlertSearch subscription for the in-app New Activity toggle.
*
* Enable uploads through `uploadAlertSearchAuthorization`. Disable revokes
* through `revokeAlertSearchAuthorization`. Neither call registers or deletes
* the FCM device. Local permission, settings, and the dual schedule stay in
* AccountViewView; a failed revoke returns false so that view can leave them
* unchanged instead of showing the subscription as off.
*/
import {
type AlertAuthorizationDependencies,
type AlertAuthorizationRevokeResult,
type AlertAuthorizationUploadResult,
revokeAlertSearchAuthorization,
uploadAlertSearchAuthorization,
} from "./alertAuthorization";
/**
* Upload the AlertSearch batch after the in-app setting has been enabled.
* A failure is reported and returned; it does not throw.
*/
export async function authorizeInAppNewActivity(
reportError: (message: string) => void,
dependencies?: AlertAuthorizationDependencies,
): Promise<AlertAuthorizationUploadResult> {
const result = await uploadAlertSearchAuthorization(dependencies);
if (!result.ok) {
reportError(result.errorMessage);
}
return result;
}
/**
* Revoke the push AlertSearch authorization for the in-app toggle.
*
* @returns false when the delete did not succeed. Callers must not persist
* the off setting or announce that notifications are off in that case.
*/
export async function revokeInAppNewActivity(
reportError: (message: string) => void,
dependencies?: AlertAuthorizationDependencies,
): Promise<boolean> {
const result: AlertAuthorizationRevokeResult =
await revokeAlertSearchAuthorization(dependencies);
if (!result.ok) {
reportError(result.errorMessage);
return false;
}
return true;
}
+1 -1
View File
@@ -187,7 +187,7 @@ export function createNotifyHelpers(notify: NotifyFunction) {
// Notification-off modal (for turning off notifications)
notificationOff: (
title: string,
callback: (success: boolean) => Promise<void>,
callback: (success: boolean) => Promise<void | boolean>,
timeout?: number,
) =>
notify(
+48 -16
View File
@@ -1030,6 +1030,7 @@ import { includeDevToolkitRoutes } from "@/utils/includeDevToolkitRoutes";
import { AccountSettings, isApiError } from "@/interfaces/accountView";
import {
NotificationService,
authorizeInAppNewActivity,
authorizeSmsAlertSearch,
buildDualScheduleConfig,
configureNativeFetcherIfReady,
@@ -1037,6 +1038,7 @@ import {
getNotificationApiBaseUrl,
listSmsPhones,
localTimeTextToUtc,
revokeInAppNewActivity,
revokeSmsAlertSearch,
smsErrorMessage,
syncStarredPlansToNativePlugin,
@@ -1530,31 +1532,61 @@ export default class AccountViewView extends Vue {
if (Capacitor.isNativePlatform()) {
await this.scheduleNewActivityDualNotification(timeText);
}
await authorizeInAppNewActivity((message) => {
this.reportAlertSearchAuthorizationFailure("upload", message);
});
}
});
} else {
this.notify.notificationOff(DAILY_CHECK_TITLE, async (success) => {
if (success) {
if (
Capacitor.isNativePlatform() &&
DailyNotification?.cancelDualSchedule
) {
try {
await DailyNotification.cancelDualSchedule();
} catch (e) {
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
}
}
await this.$saveSettings({
notifyingNewActivityTime: "",
});
this.notifyingNewActivity = false;
this.notifyingNewActivityTime = "";
if (!success) {
return;
}
// Revoke before clearing the local setting. A failed delete leaves
// the toggle on so the dialog does not claim the subscription is gone.
const revoked = await revokeInAppNewActivity((message) => {
this.reportAlertSearchAuthorizationFailure("revoke", message);
});
if (!revoked) {
return false;
}
if (
Capacitor.isNativePlatform() &&
DailyNotification?.cancelDualSchedule
) {
try {
await DailyNotification.cancelDualSchedule();
} catch (e) {
logger.error("[AccountViewView] cancelDualSchedule failed:", e);
}
}
await this.$saveSettings({
notifyingNewActivityTime: "",
});
this.notifyingNewActivity = false;
this.notifyingNewActivityTime = "";
});
}
}
/**
* Surface an AlertSearch upload or revoke failure. The message states the
* failure; callers must not follow it with a success announcement.
*/
private reportAlertSearchAuthorizationFailure(
action: "upload" | "revoke",
message: string,
): void {
logger.error(
`[AccountViewView] AlertSearch authorization ${action} failed:`,
message,
);
this.notify.error(
`AlertSearch authorization ${action} failed: ${message}`,
TIMEOUTS.LONG,
);
}
/**
* Toggle the SMS channel as a whole ("Enable Text Messages" / "SMS Text
* Message" master switch).