feat(push): add Web Push (VAPID) for lock-screen notifications on iPad PWA
Task #554. Adds a full Web Push stack on top of the existing Socket.IO notification fan-out so the iPad PWA (and any installed browser) receives system notifications when Tx OS is closed or backgrounded. Backend - New `push_subscriptions` table (userId + unique endpoint + p256dh/auth keys + ua + timestamps), exported from `@workspace/db`. - `artifacts/api-server/src/lib/push.ts`: - VAPID bootstrap from env, else cached file at LOCAL_STORAGE_ROOT (Docker volume) with /tmp fallback for Replit dev, else ephemeral. - `sendPushToUser()` honours `notificationsMuted` + per-channel prefs (orders/meetings/notes), prunes 404/410 endpoints, truncates payload bodies to ~3500 bytes. - **De-dup gate:** skips push when the user has any active Socket.IO connection (uses `io.in(\`user:\${uid}\`).fetchSockets()`), so a connected user only gets the in-app chime, never a duplicate system notification. - `upsertSubscription()` deletes a stale row first when the same browser endpoint flips to a different user (shared device) so the previous user's notifications can't leak. - Three new routes: `GET /api/push/vapid-public-key`, `POST /api/push/subscribe`, `POST /api/push/unsubscribe` (auth-gated, Zod-validated). - Push hooked into 4 existing emit sites: service-orders `notifyUser`, notes new-note + reply, executive-meeting broadcast. Frontend - `artifacts/tx-os/public/sw.js`: push + notificationclick only (no asset caching). All URLs (icon, badge, navigation target) resolved against `self.registration.scope`, so the SW works at root or under a subpath without code changes. - SW registration in `main.tsx` scoped to `BASE_URL`. - `use-push-subscription` hook (enable/disable/refresh + status). - New `PushEnablePrompt` card mounted in `App` — appears on first launch when supported + permission still "default", one-tap enable, dismiss persists for 14 days. Silent on unsupported devices. - `PushToggleRow` added inside Notification Settings. - ar/en strings: `notifSettings.push.*` and `common.later`. Plumbing - OpenAPI: 3 new operations under `notifications`; orval codegen run. - `docker-compose.yml` passes VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_SUBJECT through to the api service. - `pnpm --filter @workspace/db run push` applied the schema. - web-push + @types/web-push installed in api-server. Verification - API restarts clean; `/api/push/vapid-public-key` returns the key; subscribe endpoint 401s without auth. - New `artifacts/api-server/tests/push.test.mjs` — 7/7 passing — covers VAPID endpoint, auth gating on subscribe/unsubscribe, row persistence + removal, idempotent re-subscribe with key rotation, account-switch endpoint reassignment, and malformed-input rejection. - Two architect rounds: first flagged race + payload size (fixed), second flagged dedup gate + first-launch UX + SW base-path + tests (all fixed in this commit).
This commit is contained in:
@@ -159,6 +159,23 @@ async function userAllowsChannel(
|
||||
* owns. Drops 404/410 subscriptions on the fly. Honours the user's
|
||||
* mute + per-channel preferences. Safe to fire-and-forget.
|
||||
*/
|
||||
/**
|
||||
* Returns true if the user has at least one active Socket.IO connection
|
||||
* (any tab/device). Used to gate Web Push so a connected user doesn't
|
||||
* get the in-app chime AND a system notification for the same event.
|
||||
*/
|
||||
async function isUserConnected(userId: number): Promise<boolean> {
|
||||
try {
|
||||
const { io } = await import("../index.js");
|
||||
const sockets = await io.in(`user:${userId}`).fetchSockets();
|
||||
return sockets.length > 0;
|
||||
} catch {
|
||||
// If the socket layer isn't reachable, fall through and send push —
|
||||
// better to over-notify than to silently drop.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPushToUser(
|
||||
userId: number,
|
||||
payload: PushPayload,
|
||||
@@ -168,6 +185,12 @@ export async function sendPushToUser(
|
||||
const allowed = await userAllowsChannel(userId, channel);
|
||||
if (!allowed) return;
|
||||
|
||||
// De-dup: if the user is online (any tab/device with an active socket)
|
||||
// they're already getting the in-app notification via Socket.IO, so
|
||||
// we skip Web Push to avoid double-alerts. Push is for the
|
||||
// backgrounded/closed PWA case.
|
||||
if (await isUserConnected(userId)) return;
|
||||
|
||||
const vapid = await getVapid();
|
||||
if (!vapid) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// API tests for the Web Push subsystem:
|
||||
// - GET /api/push/vapid-public-key
|
||||
// - POST /api/push/subscribe (auth + upsert)
|
||||
// - POST /api/push/unsubscribe (auth + removal)
|
||||
// - account-switch row reassignment for the same browser endpoint
|
||||
//
|
||||
// The full delivery path (web-push -> 410 cleanup) is exercised via a
|
||||
// direct DB-level simulation: we insert a subscription with a known
|
||||
// endpoint, then assert it can be looked up + removed through the
|
||||
// public API. The 410 cleanup itself runs in the server when push
|
||||
// providers reject — covered by reading the helper from
|
||||
// `dist/index.mjs` would require a separate integration runner, so
|
||||
// here we cover the DB/HTTP contract.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdEndpoints = [];
|
||||
|
||||
function uniq() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix) {
|
||||
const username = `${prefix}_${uniq()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function login(username) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password: TEST_PASSWORD }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
const sid = setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
assert.ok(sid, "expected connect.sid cookie");
|
||||
return sid;
|
||||
}
|
||||
|
||||
function fakeSub(suffix) {
|
||||
const endpoint = `https://fcm.example.test/push/${suffix}_${uniq()}`;
|
||||
createdEndpoints.push(endpoint);
|
||||
return {
|
||||
endpoint,
|
||||
keys: {
|
||||
p256dh: "BPubKey-" + suffix,
|
||||
auth: "auth-" + suffix,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// Sanity: server up?
|
||||
const r = await fetch(`${API_BASE}/api/healthz`).catch(() => null);
|
||||
if (!r || !r.ok) {
|
||||
throw new Error(`API at ${API_BASE} is not reachable`);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdEndpoints.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ANY($1::text[])`,
|
||||
[createdEndpoints],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("GET /api/push/vapid-public-key returns a base64url key", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/push/vapid-public-key`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(
|
||||
typeof body.publicKey === "string" && body.publicKey.length > 40,
|
||||
"expected non-empty VAPID public key",
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/push/subscribe requires auth", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fakeSub("noauth")),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("POST /api/push/unsubscribe requires auth", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/push/unsubscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: "https://example.test/x" }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("subscribe persists a row and unsubscribe removes it", async () => {
|
||||
const user = await createUser("push_basic");
|
||||
const cookie = await login(user.username);
|
||||
const sub = fakeSub("basic");
|
||||
|
||||
const subRes = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(subRes.status, 200);
|
||||
|
||||
const rowsAfter = await pool.query(
|
||||
`SELECT user_id, p256dh, auth FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rowsAfter.rowCount, 1, "expected one row after subscribe");
|
||||
assert.equal(rowsAfter.rows[0].user_id, user.id);
|
||||
assert.equal(rowsAfter.rows[0].p256dh, sub.keys.p256dh);
|
||||
assert.equal(rowsAfter.rows[0].auth, sub.keys.auth);
|
||||
|
||||
const unsubRes = await fetch(`${API_BASE}/api/push/unsubscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
assert.equal(unsubRes.status, 200);
|
||||
|
||||
const rowsGone = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rowsGone.rowCount, 0, "expected row removed after unsubscribe");
|
||||
});
|
||||
|
||||
test("subscribe is idempotent — re-subscribing updates keys without duplicating", async () => {
|
||||
const user = await createUser("push_idem");
|
||||
const cookie = await login(user.username);
|
||||
const sub = fakeSub("idem");
|
||||
|
||||
for (const variant of [sub, { ...sub, keys: { p256dh: "rotated", auth: "rotated" } }]) {
|
||||
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify(variant),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
}
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT p256dh, auth FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
assert.equal(rows.rows[0].p256dh, "rotated");
|
||||
assert.equal(rows.rows[0].auth, "rotated");
|
||||
});
|
||||
|
||||
test("subscribe reassigns endpoint to the new user when a different user signs in on the same browser", async () => {
|
||||
const userA = await createUser("push_a");
|
||||
const userB = await createUser("push_b");
|
||||
const cookieA = await login(userA.username);
|
||||
const cookieB = await login(userB.username);
|
||||
const sub = fakeSub("switch");
|
||||
|
||||
let res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookieA },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookieB },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT user_id FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1, "endpoint should never duplicate across users");
|
||||
assert.equal(
|
||||
rows.rows[0].user_id,
|
||||
userB.id,
|
||||
"endpoint should now belong to the most recent signer-in",
|
||||
);
|
||||
});
|
||||
|
||||
test("subscribe rejects malformed input", async () => {
|
||||
const user = await createUser("push_bad");
|
||||
const cookie = await login(user.username);
|
||||
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ endpoint: "not-a-url", keys: {} }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
@@ -1,23 +1,26 @@
|
||||
/* Tx OS service worker — Web Push only.
|
||||
*
|
||||
* Scope: served from the SPA's base path (e.g. `/sw.js`). The SPA
|
||||
* registers it with `scope: BASE_URL` so the SW controls the full
|
||||
* Tx OS surface but doesn't intercept anything outside the artifact.
|
||||
* Scope: registered with `scope: BASE_URL`, so the SW only controls
|
||||
* paths under the SPA's base path (e.g. `/`, or `/tx-os/` in a future
|
||||
* subpath deployment). Every URL the SW touches — icons, navigation
|
||||
* targets — is resolved against `self.registration.scope` so the same
|
||||
* code works at root and at a subpath without code changes.
|
||||
*
|
||||
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
|
||||
* on a single machine, so the network is fast and reliable. Adding
|
||||
* cache layers would risk shipping stale React bundles after an
|
||||
* upgrade.
|
||||
*
|
||||
* Events:
|
||||
* - install / activate: claim clients immediately so the first push
|
||||
* after registration lands without a reload.
|
||||
* - push: render a system notification (lock-screen + banner on iPad
|
||||
* PWA, system tray on macOS, etc.).
|
||||
* - notificationclick: focus an existing Tx OS tab (or open one) and
|
||||
* navigate to the payload's URL.
|
||||
*/
|
||||
|
||||
function scopedUrl(relative) {
|
||||
// self.registration.scope is the full origin + base path with a
|
||||
// trailing slash (e.g. "https://host/" or "https://host/tx-os/").
|
||||
// Strip a leading slash from `relative` so URL() treats it as a
|
||||
// path under scope rather than origin-relative.
|
||||
const cleaned = String(relative || "").replace(/^\/+/, "");
|
||||
return new URL(cleaned, self.registration.scope).pathname;
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
@@ -36,12 +39,13 @@ self.addEventListener("push", (event) => {
|
||||
}
|
||||
|
||||
const title = payload.title || "Tx OS";
|
||||
const iconUrl = scopedUrl("icons/icon-192.png");
|
||||
const options = {
|
||||
body: payload.body || "",
|
||||
tag: payload.tag || payload.type || "tx-os",
|
||||
renotify: true,
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
icon: iconUrl,
|
||||
badge: iconUrl,
|
||||
data: {
|
||||
url: payload.url || "/",
|
||||
type: payload.type || null,
|
||||
@@ -53,7 +57,8 @@ self.addEventListener("push", (event) => {
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const targetPath = (event.notification.data && event.notification.data.url) || "/";
|
||||
const rawTarget = (event.notification.data && event.notification.data.url) || "/";
|
||||
const targetPath = scopedUrl(rawTarget);
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
@@ -61,8 +66,8 @@ self.addEventListener("notificationclick", (event) => {
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
// Prefer an already-open Tx OS tab; navigate it to targetPath
|
||||
// and focus.
|
||||
// Prefer an already-open Tx OS tab under our scope; navigate it
|
||||
// to the target and focus.
|
||||
for (const client of allClients) {
|
||||
try {
|
||||
await client.focus();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
|
||||
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import { PushEnablePrompt } from "@/components/push-enable-prompt";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import LoginPage from "@/pages/login";
|
||||
import RegisterPage from "@/pages/register";
|
||||
@@ -55,6 +56,7 @@ function Router() {
|
||||
<NotificationsSocketBridge />
|
||||
<UpcomingMeetingAlert />
|
||||
<IncomingNotePopup />
|
||||
<PushEnablePrompt />
|
||||
<Switch>
|
||||
<Route path="/login" component={LoginPage} />
|
||||
<Route path="/register" component={RegisterPage} />
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bell, X } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { usePushSubscription } from "@/hooks/use-push-subscription";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const DISMISS_KEY = "tx.pushPrompt.dismissedAt";
|
||||
// Re-prompt after 14 days if the user dismissed but never opted in.
|
||||
const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* One-tap "Enable lock-screen alerts" card surfaced after the user logs
|
||||
* in for the first time on a device. Renders only when:
|
||||
* - the user is authenticated
|
||||
* - Web Push is supported (Notifications API + SW + PushManager)
|
||||
* - permission is still "default" (we haven't asked yet)
|
||||
* - the user has no active subscription on this device
|
||||
* - they haven't dismissed the prompt recently
|
||||
*
|
||||
* On unsupported devices (notably iOS Safari before "Add to Home
|
||||
* Screen") we stay silent — the toggle inside Notification Settings
|
||||
* exposes the relevant copy for power users. We don't want a
|
||||
* permanent "not supported" banner cluttering the home screen.
|
||||
*/
|
||||
export function PushEnablePrompt() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { status, busy, enable } = usePushSubscription();
|
||||
const [hidden, setHidden] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
if (status !== "default") {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISS_KEY);
|
||||
if (raw) {
|
||||
const ts = Number(raw);
|
||||
if (
|
||||
Number.isFinite(ts) &&
|
||||
Date.now() - ts < RE_PROMPT_AFTER_MS
|
||||
) {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable — just show the prompt */
|
||||
}
|
||||
setHidden(false);
|
||||
}, [user, status]);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setHidden(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
data-testid="push-enable-prompt"
|
||||
className="fixed inset-x-3 bottom-3 z-50 md:left-auto md:right-4 md:bottom-4 md:max-w-sm rounded-2xl border border-foreground/10 bg-background/95 backdrop-blur shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 rounded-full bg-primary/15 p-2">
|
||||
<Bell size={18} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("notifSettings.push.title")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
{t("notifSettings.push.desc")}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
data-testid="push-enable-prompt-enable"
|
||||
onClick={async () => {
|
||||
const ok = await enable();
|
||||
if (ok) {
|
||||
dismiss();
|
||||
} else {
|
||||
toast({ title: t("notifSettings.push.enableFailed") });
|
||||
}
|
||||
}}
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t("notifSettings.push.enable")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="push-enable-prompt-dismiss"
|
||||
onClick={dismiss}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-md text-muted-foreground hover:bg-foreground/5"
|
||||
>
|
||||
{t("common.later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.close")}
|
||||
onClick={dismiss}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground p-1 rounded-md hover:bg-foreground/5"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -930,6 +930,7 @@
|
||||
"edit": "تعديل",
|
||||
"add": "إضافة",
|
||||
"close": "إغلاق",
|
||||
"later": "لاحقاً",
|
||||
"loading": "جاري التحميل...",
|
||||
"error": "حدث خطأ",
|
||||
"success": "تمت العملية بنجاح",
|
||||
|
||||
@@ -842,6 +842,7 @@
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"later": "Later",
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"success": "Success",
|
||||
|
||||
Reference in New Issue
Block a user