From 6656dcb10999425ced6effbc3a486d686c9dde6f Mon Sep 17 00:00:00 2001 From: Nicolas Mowen Date: Mon, 22 Jul 2024 14:39:15 -0600 Subject: [PATCH] Implement support for notifications (#12523) * Setup basic notification page * Add basic notification implementation * Register for push notifications * Implement dispatching * Add fields * Handle image and link * Add notification config * Add field for users notification tokens * Implement saving of notification tokens * Implement VAPID key generation * Implement public key encoding * Implement webpush from server * Implement push notification handling * Make notifications config only * Add maskable icon * Use zod form to control notification settings in the UI * Use js * Always open notification * Support multiple endpoints * Handle cleaning up expired notification registrations * Correctly unsubscribe notifications * Change ttl dynamically * Add note about notification latency and features * Cleanup docs * Fix firefox pushes * Add links to docs and improve formatting * Improve wording * Fix docstring Co-authored-by: Blake Blackshear * Handle case where native auth is not enabled * Show errors in UI --------- Co-authored-by: Blake Blackshear --- docker/main/requirements-wheels.txt | 5 +- docs/docs/configuration/notifications.md | 42 +++ docs/docs/configuration/reference.md | 12 +- docs/sidebars.js | 1 + frigate/api/app.py | 2 + frigate/api/notification.py | 65 ++++ frigate/app.py | 4 + frigate/comms/webpush.py | 189 ++++++++++ frigate/config.py | 8 + frigate/models.py | 1 + migrations/026_add_notification_tokens.py | 40 ++ package-lock.json | 6 + web/public/images/maskable-badge.png | Bin 0 -> 4965 bytes web/public/notifications-worker.js | 39 ++ web/site.webmanifest | 6 + web/src/pages/Settings.tsx | 38 +- web/src/types/frigateConfig.ts | 7 +- .../settings/NotificationsSettingsView.tsx | 344 ++++++++++++++++++ 18 files changed, 795 insertions(+), 14 deletions(-) create mode 100644 docs/docs/configuration/notifications.md create mode 100644 frigate/api/notification.py create mode 100644 frigate/comms/webpush.py create mode 100644 migrations/026_add_notification_tokens.py create mode 100644 package-lock.json create mode 100644 web/public/images/maskable-badge.png create mode 100644 web/public/notifications-worker.js create mode 100644 web/src/views/settings/NotificationsSettingsView.tsx diff --git a/docker/main/requirements-wheels.txt b/docker/main/requirements-wheels.txt index 4b4e13850e..f07dc149d0 100644 --- a/docker/main/requirements-wheels.txt +++ b/docker/main/requirements-wheels.txt @@ -36,4 +36,7 @@ chromadb == 0.5.0 # Generative AI google-generativeai == 0.6.* ollama == 0.2.* -openai == 1.30.* \ No newline at end of file +openai == 1.30.* +# push notifications +py-vapid == 1.9.* +pywebpush == 2.0.* \ No newline at end of file diff --git a/docs/docs/configuration/notifications.md b/docs/docs/configuration/notifications.md new file mode 100644 index 0000000000..9225ea6e87 --- /dev/null +++ b/docs/docs/configuration/notifications.md @@ -0,0 +1,42 @@ +--- +id: notifications +title: Notifications +--- + +# Notifications + +Frigate offers native notifications using the [WebPush Protocol](https://web.dev/articles/push-notifications-web-push-protocol) which uses the [VAPID spec](https://tools.ietf.org/html/draft-thomson-webpush-vapid) to deliver notifications to web apps using encryption. + +## Setting up Notifications + +In order to use notifications the following requirements must be met: + +- Frigate must be accessed via a secure https connection +- A supported browser must be used. Currently Chrome, Firefox, and Safari are known to be supported. +- In order for notifications to be usable externally, Frigate must be accessible externally + +### Configuration + +To configure notifications, go to the Frigate WebUI -> Settings -> Notifications and enable, then fill out the fields and save. + +### Registration + +Once notifications are enabled, press the `Register for Notifications` button on all devices that you would like to receive notifications on. This will register the background worker. After this Frigate must be restarted and then notifications will begin to be sent. + +## Supported Notifications + +Currently notifications are only supported for review alerts. More notifications will be supported in the future. + +:::note + +Currently, only Chrome supports images in notifications. Safari and Firefox will only show a title and message in the notification. + +::: + +## Reduce Notification Latency + +Different platforms handle notifications differently, some settings changes may be required to get optimal notification delivery. + +### Android + +Most Android phones have battery optimization settings. To get reliable Notification delivery the browser (Chrome, Firefox) should have battery optimizations disabled. If Frigate is running as a PWA then the Frigate app should have battery optimizations disabled as well. \ No newline at end of file diff --git a/docs/docs/configuration/reference.md b/docs/docs/configuration/reference.md index fa94c98aa3..8c11836b47 100644 --- a/docs/docs/configuration/reference.md +++ b/docs/docs/configuration/reference.md @@ -372,6 +372,14 @@ motion: # Optional: Delay when updating camera motion through MQTT from ON -> OFF (default: shown below). mqtt_off_delay: 30 +# Optional: Notification Configuration +notifications: + # Optional: Enable notification service (default: shown below) + enabled: False + # Optional: Email for push service to reach out to + # NOTE: This is required to use notifications + email: "admin@example.com" + # Optional: Record configuration # NOTE: Can be overridden at the camera level record: @@ -642,8 +650,8 @@ cameras: user: admin # Optional: password for login. password: admin - # Optional: Ignores time synchronization mismatches between the camera and the server during authentication. - # Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents. + # Optional: Ignores time synchronization mismatches between the camera and the server during authentication. + # Using NTP on both ends is recommended and this should only be set to True in a "safe" environment due to the security risk it represents. ignore_time_mismatch: False # Optional: PTZ camera object autotracking. Keeps a moving object in # the center of the frame by automatically moving the PTZ camera. diff --git a/docs/sidebars.js b/docs/sidebars.js index 1e1a270464..9a6ba0df9d 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -54,6 +54,7 @@ module.exports = { ], "Extra Configuration": [ "configuration/authentication", + "configuration/notifications", "configuration/hardware_acceleration", "configuration/ffmpeg_presets", "configuration/tls", diff --git a/frigate/api/app.py b/frigate/api/app.py index 0e3b0fecde..79a78f4545 100644 --- a/frigate/api/app.py +++ b/frigate/api/app.py @@ -19,6 +19,7 @@ from frigate.api.event import EventBp from frigate.api.export import ExportBp from frigate.api.media import MediaBp +from frigate.api.notification import NotificationBp from frigate.api.preview import PreviewBp from frigate.api.review import ReviewBp from frigate.config import FrigateConfig @@ -48,6 +49,7 @@ bp.register_blueprint(PreviewBp) bp.register_blueprint(ReviewBp) bp.register_blueprint(AuthBp) +bp.register_blueprint(NotificationBp) def create_app( diff --git a/frigate/api/notification.py b/frigate/api/notification.py new file mode 100644 index 0000000000..2fe61882f6 --- /dev/null +++ b/frigate/api/notification.py @@ -0,0 +1,65 @@ +"""Notification apis.""" + +import logging +import os + +from cryptography.hazmat.primitives import serialization +from flask import ( + Blueprint, + current_app, + jsonify, + make_response, + request, +) +from peewee import DoesNotExist +from py_vapid import Vapid01, utils + +from frigate.const import CONFIG_DIR +from frigate.models import User + +logger = logging.getLogger(__name__) + +NotificationBp = Blueprint("notifications", __name__) + + +@NotificationBp.route("/notifications/pubkey", methods=["GET"]) +def get_vapid_pub_key(): + if not current_app.frigate_config.notifications.enabled: + return make_response( + jsonify({"success": False, "message": "Notifications are not enabled."}), + 400, + ) + + key = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) + raw_pub = key.public_key.public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + return jsonify(utils.b64urlencode(raw_pub)), 200 + + +@NotificationBp.route("/notifications/register", methods=["POST"]) +def register_notifications(): + if current_app.frigate_config.auth.enabled: + username = request.headers.get("remote-user", type=str) or "admin" + else: + username = "admin" + + json: dict[str, any] = request.get_json(silent=True) or {} + sub = json.get("sub") + + if not sub: + return jsonify( + {"success": False, "message": "Subscription must be provided."} + ), 400 + + try: + User.update(notification_tokens=User.notification_tokens.append(sub)).where( + User.username == username + ).execute() + return make_response( + jsonify({"success": True, "message": "Successfully saved token."}), 200 + ) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Could not find user."}), 404 + ) diff --git a/frigate/app.py b/frigate/app.py index ef9360354d..dcc61a58c6 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -25,6 +25,7 @@ from frigate.comms.dispatcher import Communicator, Dispatcher from frigate.comms.inter_process import InterProcessCommunicator from frigate.comms.mqtt import MqttClient +from frigate.comms.webpush import WebPushClient from frigate.comms.ws import WebSocketClient from frigate.comms.zmq_proxy import ZmqProxy from frigate.config import FrigateConfig @@ -401,6 +402,9 @@ def init_dispatcher(self) -> None: if self.config.mqtt.enabled: comms.append(MqttClient(self.config)) + if self.config.notifications.enabled: + comms.append(WebPushClient(self.config)) + comms.append(WebSocketClient(self.config)) comms.append(self.inter_process_communicator) diff --git a/frigate/comms/webpush.py b/frigate/comms/webpush.py new file mode 100644 index 0000000000..084f91059b --- /dev/null +++ b/frigate/comms/webpush.py @@ -0,0 +1,189 @@ +"""Handle sending notifications for Frigate via Firebase.""" + +import datetime +import json +import logging +import os +from typing import Any, Callable + +from py_vapid import Vapid01 +from pywebpush import WebPusher + +from frigate.comms.dispatcher import Communicator +from frigate.config import FrigateConfig +from frigate.const import CONFIG_DIR +from frigate.models import User + +logger = logging.getLogger(__name__) + + +class WebPushClient(Communicator): # type: ignore[misc] + """Frigate wrapper for webpush client.""" + + def __init__(self, config: FrigateConfig) -> None: + self.config = config + self.claim_headers: dict[str, dict[str, str]] = {} + self.refresh: int = 0 + self.web_pushers: dict[str, list[WebPusher]] = {} + self.expired_subs: dict[str, list[str]] = {} + + if not self.config.notifications.email: + logger.warning("Email must be provided for push notifications to be sent.") + + # Pull keys from PEM or generate if they do not exist + self.vapid = Vapid01.from_file(os.path.join(CONFIG_DIR, "notifications.pem")) + + users: list[User] = ( + User.select(User.username, User.notification_tokens).dicts().iterator() + ) + for user in users: + self.web_pushers[user["username"]] = [] + for sub in user["notification_tokens"]: + self.web_pushers[user["username"]].append(WebPusher(sub)) + + def subscribe(self, receiver: Callable) -> None: + """Wrapper for allowing dispatcher to subscribe.""" + pass + + def check_registrations(self) -> None: + # check for valid claim or create new one + now = datetime.datetime.now().timestamp() + if len(self.claim_headers) == 0 or self.refresh < now: + self.refresh = int( + (datetime.datetime.now() + datetime.timedelta(hours=1)).timestamp() + ) + endpoints: set[str] = set() + + # get a unique set of push endpoints + for pushers in self.web_pushers.values(): + for push in pushers: + endpoint: str = push.subscription_info["endpoint"] + endpoints.add(endpoint[0 : endpoint.index("/", 10)]) + + # create new claim + for endpoint in endpoints: + claim = { + "sub": f"mailto:{self.config.notifications.email}", + "aud": endpoint, + "exp": self.refresh, + } + self.claim_headers[endpoint] = self.vapid.sign(claim) + + def cleanup_registrations(self) -> None: + # delete any expired subs + if len(self.expired_subs) > 0: + for user, expired in self.expired_subs.items(): + user_subs = [] + + # get all subscriptions, removing ones that are expired + stored_user: User = User.get_by_id(user) + for token in stored_user.notification_tokens: + if token["endpoint"] in expired: + continue + + user_subs.append(token) + + # overwrite the database and reset web pushers + User.update(notification_tokens=user_subs).where( + User.username == user + ).execute() + + self.web_pushers[user] = [] + + for sub in user_subs: + self.web_pushers[user].append(WebPusher(sub)) + + logger.info( + f"Cleaned up {len(expired)} notification subscriptions for {user}" + ) + + self.expired_subs = {} + + def publish(self, topic: str, payload: Any, retain: bool = False) -> None: + """Wrapper for publishing when client is in valid state.""" + if topic == "reviews": + self.send_alert(json.loads(payload)) + + def send_alert(self, payload: dict[str, any]) -> None: + if not self.config.notifications.email: + return + + self.check_registrations() + + # Only notify for alerts + if payload["after"]["severity"] != "alert": + return + + state = payload["type"] + + # Don't notify if message is an update and important fields don't have an update + if ( + state == "update" + and len(payload["before"]["data"]["objects"]) + == len(payload["after"]["data"]["objects"]) + and len(payload["before"]["data"]["zones"]) + == len(payload["after"]["data"]["zones"]) + ): + return + + reviewId = payload["after"]["id"] + sorted_objects: set[str] = set() + + for obj in payload["after"]["data"]["objects"]: + if "-verified" not in obj: + sorted_objects.add(obj) + + sorted_objects.update(payload["after"]["data"]["sub_labels"]) + + camera: str = payload["after"]["camera"] + title = f"{', '.join(sorted_objects).replace('_', ' ').title()}{' was' if state == 'end' else ''} detected in {', '.join(payload['after']['data']['zones']).replace('_', ' ').title()}" + message = f"Detected on {camera.replace('_', ' ').title()}" + image = f'{payload["after"]["thumb_path"].replace("/media/frigate", "")}' + + # if event is ongoing open to live view otherwise open to recordings view + direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}" + + for user, pushers in self.web_pushers.items(): + for pusher in pushers: + endpoint = pusher.subscription_info["endpoint"] + + # set headers for notification behavior + headers = self.claim_headers[ + endpoint[0 : endpoint.index("/", 10)] + ].copy() + headers["urgency"] = "high" + ttl = 3600 if state == "end" else 0 + + # send message + resp = pusher.send( + headers=headers, + ttl=ttl, + data=json.dumps( + { + "title": title, + "message": message, + "direct_url": direct_url, + "image": image, + "id": reviewId, + } + ), + ) + + if resp.status_code == 201: + pass + elif resp.status_code == 404 or resp.status_code == 410: + # subscription is not found or has been unsubscribed + if not self.expired_subs.get(user): + self.expired_subs[user] = [] + + self.expired_subs[user].append(pusher.subscription_info["endpoint"]) + # the subscription no longer exists and should be removed + else: + logger.warning( + f"Failed to send notification to {user} :: {resp.headers}" + ) + + self.cleanup_registrations() + + def stop(self) -> None: + pass diff --git a/frigate/config.py b/frigate/config.py index 20a540919c..126d964ffb 100644 --- a/frigate/config.py +++ b/frigate/config.py @@ -169,6 +169,11 @@ class AuthConfig(FrigateBaseModel): hash_iterations: int = Field(default=600000, title="Password hash iterations") +class NotificationConfig(FrigateBaseModel): + enabled: bool = Field(default=False, title="Enable notifications") + email: Optional[str] = Field(default=None, title="Email required for push.") + + class StatsConfig(FrigateBaseModel): amd_gpu_stats: bool = Field(default=True, title="Enable AMD GPU stats.") intel_gpu_stats: bool = Field(default=True, title="Enable Intel GPU stats.") @@ -1361,6 +1366,9 @@ class FrigateConfig(FrigateBaseModel): default_factory=dict, title="Frigate environment variables." ) ui: UIConfig = Field(default_factory=UIConfig, title="UI configuration.") + notifications: NotificationConfig = Field( + default_factory=NotificationConfig, title="Notification Config" + ) telemetry: TelemetryConfig = Field( default_factory=TelemetryConfig, title="Telemetry configuration." ) diff --git a/frigate/models.py b/frigate/models.py index b6588ed3b6..c73033b3e6 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -118,3 +118,4 @@ class Meta: class User(Model): # type: ignore[misc] username = CharField(null=False, primary_key=True, max_length=30) password_hash = CharField(null=False, max_length=120) + notification_tokens = JSONField() diff --git a/migrations/026_add_notification_tokens.py b/migrations/026_add_notification_tokens.py new file mode 100644 index 0000000000..37506c4063 --- /dev/null +++ b/migrations/026_add_notification_tokens.py @@ -0,0 +1,40 @@ +"""Peewee migrations + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import peewee as pw +from playhouse.sqlite_ext import JSONField + +from frigate.models import User + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.add_fields( + User, + notification_tokens=JSONField(default=[]), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + pass diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..13ac760ada --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "frigate", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/public/images/maskable-badge.png b/web/public/images/maskable-badge.png new file mode 100644 index 0000000000000000000000000000000000000000..856f14223b08e49e96e4911de12f546b7c749d53 GIT binary patch literal 4965 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 { + // @ts-expect-error we know this exists + if (event.notification) { + // @ts-expect-error we know this exists + event.notification.close(); + + // @ts-expect-error we know this exists + if (event.notification.data) { + const url = event.notification.data.link; + // eslint-disable-next-line no-undef + if (clients.openWindow) { + // eslint-disable-next-line no-undef + return clients.openWindow(url); + } + } + } +}); diff --git a/web/site.webmanifest b/web/site.webmanifest index 53a45654be..94e455ec85 100644 --- a/web/site.webmanifest +++ b/web/site.webmanifest @@ -20,6 +20,12 @@ "sizes": "180x180", "type": "image/png", "purpose": "maskable" + }, + { + "src": "/images/maskable-badge.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable" } ], "theme_color": "#ffffff", diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index c355a97c67..6d147b3327 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -35,24 +35,39 @@ import ObjectSettingsView from "@/views/settings/ObjectSettingsView"; import MotionTunerView from "@/views/settings/MotionTunerView"; import MasksAndZonesView from "@/views/settings/MasksAndZonesView"; import AuthenticationView from "@/views/settings/AuthenticationView"; +import NotificationView from "@/views/settings/NotificationsSettingsView"; -export default function Settings() { - const settingsViews = [ - "general", - "camera settings", - "masks / zones", - "motion tuner", - "debug", - "users", - ] as const; +const allSettingsViews = [ + "general", + "camera settings", + "masks / zones", + "motion tuner", + "debug", + "users", + "notifications", +] as const; +type SettingsType = (typeof allSettingsViews)[number]; - type SettingsType = (typeof settingsViews)[number]; +export default function Settings() { const [page, setPage] = useState("general"); const [pageToggle, setPageToggle] = useOptimisticState(page, setPage, 100); const tabsRef = useRef(null); const { data: config } = useSWR("config"); + // available settings views + + const settingsViews = useMemo(() => { + const views = [...allSettingsViews]; + + if (!("Notification" in window) || !window.isSecureContext) { + const index = views.indexOf("notifications"); + views.splice(index, 1); + } + + return views; + }, []); + // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -181,6 +196,9 @@ export default function Settings() { /> )} {page == "users" && } + {page == "notifications" && ( + + )} {confirmationDialogOpen && ( >; +}; +export default function NotificationView({ + setUnsavedChanges, +}: NotificationsSettingsViewProps) { + const { data: config, mutate: updateConfig } = useSWR( + "config", + { + revalidateOnFocus: false, + }, + ); + + // status bar + + const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!; + + // notification key handling + + const { data: publicKey } = useSWR( + config?.notifications?.enabled ? "notifications/pubkey" : null, + { revalidateOnFocus: false }, + ); + + const subscribeToNotifications = useCallback( + (registration: ServiceWorkerRegistration) => { + if (registration) { + addMessage( + "notification_settings", + "Unsaved Notification Registrations", + undefined, + "registration", + ); + + registration.pushManager + .subscribe({ + userVisibleOnly: true, + applicationServerKey: publicKey, + }) + .then((pushSubscription) => { + axios + .post("notifications/register", { + sub: pushSubscription, + }) + .catch(() => { + toast.error("Failed to save notification registration.", { + position: "top-center", + }); + pushSubscription.unsubscribe(); + registration.unregister(); + setRegistration(null); + }); + toast.success( + "Successfully registered for notifications. Restart to start receiving notifications.", + { + position: "top-center", + }, + ); + }); + } + }, + [publicKey, addMessage], + ); + + // notification state + + const [registration, setRegistration] = + useState(); + + useEffect(() => { + navigator.serviceWorker + .getRegistration(NOTIFICATION_SERVICE_WORKER) + .then((worker) => { + if (worker) { + setRegistration(worker); + } else { + setRegistration(null); + } + }) + .catch(() => { + setRegistration(null); + }); + }, []); + + // form + + const [isLoading, setIsLoading] = useState(false); + const formSchema = z.object({ + enabled: z.boolean(), + email: z.string(), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + mode: "onChange", + defaultValues: { + enabled: config?.notifications.enabled, + email: config?.notifications.email, + }, + }); + + const onCancel = useCallback(() => { + if (!config) { + return; + } + + setUnsavedChanges(false); + form.reset({ + enabled: config.notifications.enabled, + email: config.notifications.email || "", + }); + // we know that these deps are correct + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [config, removeMessage, setUnsavedChanges]); + + const saveToConfig = useCallback( + async ( + { enabled, email }: NotificationSettingsValueType, // values submitted via the form + ) => { + axios + .put( + `config/set?notifications.enabled=${enabled}¬ifications.email=${email}`, + { + requires_restart: 0, + }, + ) + .then((res) => { + if (res.status === 200) { + toast.success("Notification settings have been saved.", { + position: "top-center", + }); + updateConfig(); + } else { + toast.error(`Failed to save config changes: ${res.statusText}`, { + position: "top-center", + }); + } + }) + .catch((error) => { + toast.error( + `Failed to save config changes: ${error.response.data.message}`, + { position: "top-center" }, + ); + }) + .finally(() => { + setIsLoading(false); + }); + }, + [updateConfig, setIsLoading], + ); + + function onSubmit(values: z.infer) { + setIsLoading(true); + saveToConfig(values as NotificationSettingsValueType); + } + + return ( + <> +
+ +
+ + Notification Settings + + +
+
+

+ Frigate can natively send push notifications to your device when + it is running in the browser or installed as a PWA. +

+
+ + Read the Documentation{" "} + + +
+
+
+ +
+ + ( + + +
+ + { + return field.onChange(checked); + }} + /> +
+
+
+ )} + /> + ( + + Email + + + + + Entering a valid email is required, as this is used by the + push server in case problems occur. + + + + )} + /> +
+ + +
+ + + +
+
+ + +
+
+
+
+ + ); +}