diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 7e87ba450..19886974f 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; +import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener"; import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { locales } from "@/i18n/routing"; @@ -45,6 +46,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/(main)/[locale]/plugins/oauth/callback/page.tsx b/app/(main)/[locale]/plugins/oauth/callback/page.tsx new file mode 100644 index 000000000..35872aa92 --- /dev/null +++ b/app/(main)/[locale]/plugins/oauth/callback/page.tsx @@ -0,0 +1,151 @@ +"use client"; + +/** + * Generic OAuth callback landing page for plugins. + * + * Plugins that connect to third-party OAuth providers (Google, Nextcloud, …) + * cannot register their own routes. They send the user to the provider's + * authorize URL with a redirect_uri pointing here; this page parks the + * authorization code + state and notifies the plugin that initiated the flow. + * No tokens ever pass through this page - the plugin exchanges the code + * itself (via api.http.fetch, through its origin allowlist). + * + * Delivery chain: + * 1. The payload `{ code, state, receivedAt }` is written to sessionStorage + * (`plugin-oauth-callback`) and mirrored into localStorage under the same + * key, plus a same-tab CustomEvent `plugin-oauth-callback`. + * 2. The host listens via `PluginOAuthCallbackListener` (mounted in the + * [locale] layout): the localStorage write raises a `storage` event in + * every OTHER tab of this origin, including the tab that started the + * flow. The listener forwards the payload to plugins through the + * `authHooks.onOAuthCallback` hook bus. + * 3. Each plugin validates `state` against the verifier it stashed before + * redirecting and ignores payloads that are not its own. + * + * The page then shows a status screen - the user closes the tab (or switches + * back) while the original tab completes the token exchange. + */ + +import { OAuthStatusCard } from "@/components/auth/oauth-status-card"; +import { + PLUGIN_OAUTH_EVENT, + PLUGIN_OAUTH_STORAGE_KEY, + type OAuthCallbackPayload, +} from "@/lib/plugin-oauth"; +import { CheckCircle2, XCircle } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useSearchParams } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; + +function PluginOAuthCallbackInner() { + const searchParams = useSearchParams(); + const t = useTranslations("plugins"); + const [status, setStatus] = useState<"waiting" | "ok" | "error">("waiting"); + const [errorMessage, setErrorMessage] = useState(null); + + useEffect(() => { + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const errorParam = searchParams.get("error"); + const errorDescription = searchParams.get("error_description"); + + // Build the payload to hand off. Error/denial responses still carry + // `state`, so the initiating plugin can match and terminate its pending + // flow instead of waiting forever. + let payload: OAuthCallbackPayload; + let errorMessageText: string; + if (errorParam) { + payload = { + state: state ?? undefined, + error: errorParam, + error_description: errorDescription ?? undefined, + receivedAt: Date.now(), + }; + errorMessageText = + errorParam === "access_denied" + ? t("oauth_callback.denied") + : t("oauth_callback.failed_detail", { error: errorParam }); + } else if (!code || !state) { + payload = { state: state ?? undefined, error: "invalid_request", receivedAt: Date.now() }; + errorMessageText = t("oauth_callback.missing_params"); + } else { + payload = { code, state, receivedAt: Date.now() }; + errorMessageText = ""; + } + + + let delivered = false; + try { + sessionStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // sessionStorage unavailable - the event still fires for same-tab flows + } + try { + // localStorage (unlike sessionStorage) raises a `storage` event in the + // OTHER tabs of this origin - that is what carries the payload across + // to the tab where the plugin's background instance is running. + localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + delivered = true; + } catch { + // localStorage unavailable - fall through to the same-tab CustomEvent + } + // Same-tab consumers can react immediately without waiting for storage. + window.dispatchEvent( + new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }), + ); + + if (errorMessageText) { + setErrorMessage(errorMessageText); + setStatus("error"); + } else if (delivered) { + setStatus("ok"); + } else { + // Storage write failed: the payload cannot reach the tab that started + // the flow (the listener is disabled on this route, so the same-tab + // event alone is not enough). Report failure instead of a false "ok". + setStatus("error"); + setErrorMessage(t("oauth_callback.delivery_failed")); + } + }, [searchParams, t]); + + return ( + + ); +} + +export default function PluginOAuthCallbackPage() { + return ( + }> + + + ); +} diff --git a/components/__tests__/plugin-oauth-callback-listener.test.tsx b/components/__tests__/plugin-oauth-callback-listener.test.tsx new file mode 100644 index 000000000..6b738810c --- /dev/null +++ b/components/__tests__/plugin-oauth-callback-listener.test.tsx @@ -0,0 +1,173 @@ +import { authHooks } from "@/lib/plugin-hooks"; +import { + PLUGIN_OAUTH_EVENT, + PLUGIN_OAUTH_STORAGE_KEY, +} from "@/lib/plugin-oauth"; +import { act, render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { PluginOAuthCallbackListener } from "../providers/plugin-oauth-callback-listener"; + +vi.mock("next/navigation", () => ({ + usePathname: vi.fn(), +})); + +import { usePathname } from "next/navigation"; +const mockUsePathname = vi.mocked(usePathname); + +function emitStorageEvent(newValue: string | null) { + act(() => { + window.dispatchEvent( + new StorageEvent("storage", { + key: PLUGIN_OAUTH_STORAGE_KEY, + newValue, + }), + ); + }); +} + +describe("PluginOAuthCallbackListener", () => { + let handler: (payload: unknown) => Promise; + let disposable: { dispose: () => void }; + + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + mockUsePathname.mockReturnValue("/en/mail"); + handler = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined); + disposable = authHooks.onOAuthCallback.register("test-plugin", handler); + }); + + afterEach(() => { + disposable.dispose(); + }); + + it("delivers a success payload via the storage event", () => { + const { unmount } = render(); + const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + + emitStorageEvent(JSON.stringify(payload)); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ code: "abc", state: "xyz" }), + ); + // Best-effort cleanup after delivery + expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBeNull(); + unmount(); + }); + + it("delivers an error payload (denial) with state", () => { + const { unmount } = render(); + const payload = { state: "xyz", error: "access_denied", receivedAt: 1 }; + localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + + emitStorageEvent(JSON.stringify(payload)); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ state: "xyz", error: "access_denied" }), + ); + unmount(); + }); + + it("does nothing on the callback route", () => { + mockUsePathname.mockReturnValue("/en/plugins/oauth/callback"); + const { unmount } = render(); + const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + + emitStorageEvent(JSON.stringify(payload)); + act(() => { + window.dispatchEvent( + new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }), + ); + }); + + expect(handler).not.toHaveBeenCalled(); + // No cleanup either - the payload stays parked for the other tab + expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBe(payload ? JSON.stringify(payload) : null); + unmount(); + }); + + it("ignores malformed payloads", () => { + const { unmount } = render(); + + emitStorageEvent("not json"); + emitStorageEvent(JSON.stringify({ foo: "bar" })); + emitStorageEvent(JSON.stringify({ code: 123, state: "xyz" })); + emitStorageEvent(null); + + expect(handler).not.toHaveBeenCalled(); + unmount(); + }); + + it("ignores payloads without state", () => { + const { unmount } = render(); + + // A payload with no state at all cannot be matched to any pending flow, + // so the listener drops it rather than broadcasting an unmatchable event. + emitStorageEvent(JSON.stringify({ code: "abc", receivedAt: 1 })); + emitStorageEvent(JSON.stringify({ error: "server_error", receivedAt: 1 })); + + expect(handler).not.toHaveBeenCalled(); + unmount(); + }); + + it("deduplicates identical payloads within the window", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(0); + const { unmount } = render(); + const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + + emitStorageEvent(JSON.stringify(payload)); + emitStorageEvent(JSON.stringify(payload)); + + expect(handler).toHaveBeenCalledOnce(); + + // Outside the dedup window the same payload is delivered again + vi.setSystemTime(61_000); + emitStorageEvent(JSON.stringify(payload)); + expect(handler).toHaveBeenCalledTimes(2); + + unmount(); + } finally { + vi.useRealTimers(); + } + }); + + it("delivers same-tab custom events", () => { + const { unmount } = render(); + const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + + act(() => { + window.dispatchEvent( + new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }), + ); + }); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ code: "abc", state: "xyz" }), + ); + unmount(); + }); + + it("stops listening after unmount", () => { + const { unmount } = render(); + unmount(); + + const payload = { code: "abc", state: "xyz", receivedAt: 1 }; + localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload)); + emitStorageEvent(JSON.stringify(payload)); + act(() => { + window.dispatchEvent( + new CustomEvent(PLUGIN_OAUTH_EVENT, { detail: payload }), + ); + }); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/components/auth/oauth-status-card.tsx b/components/auth/oauth-status-card.tsx new file mode 100644 index 000000000..d4b6348bf --- /dev/null +++ b/components/auth/oauth-status-card.tsx @@ -0,0 +1,113 @@ +"use client"; + +/** + * Branded full-screen status card for OAuth callback pages. + */ + +import { Button } from "@/components/ui/button"; +import { useConfig } from "@/hooks/use-config"; +import { withBasePath } from "@/lib/browser-navigation"; +import { cn } from "@/lib/utils"; +import { useThemeStore } from "@/stores/theme-store"; +import type { LucideIcon } from "lucide-react"; +import { Loader2 } from "lucide-react"; + +export interface OAuthStatusAction { + label: string; + onClick: () => void; +} + +export interface OAuthStatusCardProps { + /** Icon badge shown below the logo (defaults to a spinner) */ + icon?: LucideIcon; + /** Tailwind color classes for the icon badge, e.g. "bg-green-500/10 text-green-600" */ + iconClassName?: string; + title: string; + message?: string; + action?: OAuthStatusAction; + /** Extra content rendered below the message (e.g. detail lines) */ + children?: React.ReactNode; +} + +export function OAuthStatusCard({ + icon: Icon, + iconClassName, + title, + message, + action, + children, +}: OAuthStatusCardProps) { + const { + appName, + appLogoLightUrl, + appLogoDarkUrl, + loginLogoLightUrl, + loginLogoDarkUrl, + } = useConfig(); + const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + + // Same logo resolution order as the admin layout: app logo first, then the + // login logo (which has Bulwark defaults), so an unbranded instance still + // shows the Bulwark mark. + const logoUrl = withBasePath( + resolvedTheme === "dark" + ? appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl + : appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl, + ); + + return ( + + + + {/* Header with instance branding */} + + + + + + {appName} + + + {/* Status badge */} + + {Icon ? ( + + ) : ( + + )} + + + + {/* Status body */} + + {title} + {message && ( + + {message} + + )} + {children} + {action && ( + + {action.label} + + )} + + + + + ); +} diff --git a/components/providers/plugin-oauth-callback-listener.tsx b/components/providers/plugin-oauth-callback-listener.tsx new file mode 100644 index 000000000..3b4299257 --- /dev/null +++ b/components/providers/plugin-oauth-callback-listener.tsx @@ -0,0 +1,109 @@ +'use client'; + +/** + * Bridges third-party OAuth callbacks to plugins. + * + * The generic callback page (`/[locale]/plugins/oauth/callback`) parks the + * provider's `{ code, state }` payload in localStorage. A `storage` event + * fires in every OTHER same-origin tab - i.e. in the tab where the plugin's + * background sandbox instance is actually running. This listener forwards + * those payloads (and same-tab CustomEvents from the callback page) into the + * `authHooks.onOAuthCallback` hook bus so plugins can finish their PKCE + * exchange. + * + * This component intentionally does nothing when mounted ON the callback + * route: the callback tab must not process its own payload, otherwise two + * app instances would race to exchange the single-use authorization code. + */ + +import { authHooks } from '@/lib/plugin-hooks'; +import { + PLUGIN_OAUTH_EVENT as EVENT_NAME, + PLUGIN_OAUTH_STORAGE_KEY as STORAGE_KEY, + type OAuthCallbackPayload, +} from '@/lib/plugin-oauth'; +import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; + +/** Window in which identical payloads are treated as duplicates. */ +const DEDUP_MS = 60_000; + +function parsePayload(raw: string | null | undefined): OAuthCallbackPayload | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Record; + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const state = typeof parsed.state === 'string' ? parsed.state : undefined; + // A payload without `state` cannot be matched to any pending flow, so + // drop it instead of broadcasting an unmatchable event. + if (state === undefined) { + return null; + } + const receivedAt = typeof parsed.receivedAt === 'number' ? parsed.receivedAt : Date.now(); + // Success payloads carry { code, ... }; error/denial payloads carry + // { error, ... }. Exactly one must identify the kind. + if (typeof parsed.code === 'string') { + return { code: parsed.code, state, receivedAt }; + } + if (typeof parsed.error === 'string') { + return { + error: parsed.error, + state, + error_description: + typeof parsed.error_description === 'string' ? parsed.error_description : undefined, + receivedAt, + }; + } + return null; + } catch { + return null; + } +} + +export function PluginOAuthCallbackListener() { + const pathname = usePathname(); + // The callback route ends with /plugins/oauth/callback (locale is a + // preceding segment). On that route this component is a no-op. + const isCallbackRoute = !!pathname && pathname.endsWith('/plugins/oauth/callback'); + + useEffect(() => { + if (isCallbackRoute) return; + + let lastCode: string | null = null; + let lastState: string | null = null; + let lastAt = 0; + + const deliver = (payload: OAuthCallbackPayload | null) => { + if (!payload) return; + const now = Date.now(); + const code = 'code' in payload ? payload.code : null; + if (code === lastCode && payload.state === lastState && now - lastAt < DEDUP_MS) return; + lastCode = code; + lastState = payload.state ?? null; + lastAt = now; + void authHooks.onOAuthCallback.emit(payload); + // Best-effort cleanup so a reload doesn't replay an old payload. + try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ } + }; + + const onStorage = (ev: StorageEvent) => { + if (ev.key !== STORAGE_KEY && ev.key !== null) return; + deliver(parsePayload(ev.newValue ?? (() => { try { return localStorage.getItem(STORAGE_KEY); } catch { return null; } })())); + }; + const onCustomEvent = (ev: Event) => { + const detail = (ev as CustomEvent).detail; + if (detail) deliver(detail); + }; + + window.addEventListener('storage', onStorage); + window.addEventListener(EVENT_NAME, onCustomEvent); + return () => { + window.removeEventListener('storage', onStorage); + window.removeEventListener(EVENT_NAME, onCustomEvent); + }; + }, [isCallbackRoute]); + + return null; +} diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 2b963fac9..fcb76f605 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -427,6 +427,15 @@ export const authHooks = { onAccountRemove: new HookBus(), onTokenRefresh: new HookBus(), onAuthReady: new HookBus(), + /** + * Fired when a third-party OAuth provider redirects back to the generic + * plugin OAuth callback page (`/[locale]/plugins/oauth/callback`). The + * payload is `{ code, state, receivedAt }` exactly as the provider sent it. + * Each plugin validates `state` against the verifier it stashed before + * redirecting, so plugins that did not initiate a flow simply ignore the + * event. See `components/providers/plugin-oauth-callback-listener.tsx`. + */ + onOAuthCallback: new HookBus(), }; // §7.6 Settings Hooks diff --git a/lib/plugin-oauth.ts b/lib/plugin-oauth.ts new file mode 100644 index 000000000..16149e104 --- /dev/null +++ b/lib/plugin-oauth.ts @@ -0,0 +1,32 @@ +/** + * Shared constants and types for the plugin OAuth callback handoff. + * + * The callback page (`/[locale]/plugins/oauth/callback`) parks the provider's + * payload in storage and dispatches events; the host relays it to plugins via + * the `authHooks.onOAuthCallback` hook bus. Both sides share the storage key, + * event name and payload contract from this module. + */ + +export const PLUGIN_OAUTH_STORAGE_KEY = "plugin-oauth-callback"; +export const PLUGIN_OAUTH_EVENT = "plugin-oauth-callback"; + +/** + * Payload delivered to plugins through `authHooks.onOAuthCallback`. + * + * Two shapes, discriminated by which field the provider returned: + * - Success: `{ code, state?, receivedAt? }` + * - Denial/failure: `{ error, state?, error_description?, receivedAt? }` + * + * `state` stays optional in both variants because a provider may omit it even + * on error; plugins match the flow against the verifier they stashed before + * redirecting and ignore payloads whose `state` does not match. Narrow with + * `'code' in payload` / `'error' in payload`. + */ +export type OAuthCallbackPayload = + | { code: string; state?: string; receivedAt?: number } + | { + error: string; + state?: string; + error_description?: string; + receivedAt?: number; + }; diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 161bdf36f..46511d708 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -27,7 +27,7 @@ import { KEYWORD_PREFIX } from '../thread-utils'; import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog'; import { fileStorage } from '../plugin-storage'; import { generateUUID } from '../utils'; -import { ContactCard, Identity } from '../jmap/types'; +import { AddressBook, ContactCard, Identity } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecurityStore } from '@/stores/account-security-store'; import { createHash } from 'crypto'; @@ -113,6 +113,11 @@ const PERM_PER_METHOD: Record = { 'contact.update': 'contacts:write', 'contact.create': 'contacts:write', 'contact.search': 'contacts:read', + 'contact.list': 'contacts:read', + 'contact.delete': 'contacts:write', + // addressbook + 'addressbook.list': 'contacts:read', + 'addressbook.create': 'contacts:write', // user 'user.getAccounts': 'account:read', 'user.getIdentities': 'identity:read', @@ -648,6 +653,42 @@ async function doContactCreate(contact: ContactCard): Promise { return await client.createContact(contact); } +async function doContactList(addressBookId?: string): Promise { + const { client } = useAuthStore.getState(); + if (!client) { + throw new Error('contact.list: no active session'); + } + // Propagate failures: a plugin must not mistake an outage for an empty + // address book. + return await client.getContacts(addressBookId, { throwOnError: true }); +} + +async function doContactDelete(contactId: string): Promise { + const { client } = useAuthStore.getState(); + if (!client) { + throw new Error('contact.delete: no active session'); + } + await client.deleteContact(contactId); +} + +async function doAddressBookList(): Promise { + const { client } = useAuthStore.getState(); + if (!client) { + throw new Error('addressbook.list: no active session'); + } + // Propagate failures so plugins can tell "no books" from a failed fetch + // (see #730). + return await client.getAddressBooks({ throwOnError: true }); +} + +async function doAddressBookCreate(name: string): Promise { + const { client } = useAuthStore.getState(); + if (!client) { + throw new Error('addressbook.create: no active session'); + } + return await client.createAddressBook(name); +} + // ─── Crypto (privileged tier) ───────────────────────────────────────────── type PRFResult = @@ -1406,6 +1447,11 @@ export async function dispatchApiCall( case 'contact.update': return doContactUpdate(args[0] as string, args[1] as Partial); case 'contact.create': return doContactCreate(args[0] as ContactCard); case 'contact.search': return doContactSearch(args[0] as string); + case 'contact.list': return doContactList(args[0] as string | undefined); + case 'contact.delete': return doContactDelete(args[0] as string); + + case 'addressbook.list': return doAddressBookList(); + case 'addressbook.create': return doAddressBookCreate(args[0] as string); case 'user.getAccounts': return doUserGetAccounts(); case 'user.getIdentities': return doUserGetIdentities(); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 1307e1ea1..6e9a5f297 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -29,7 +29,7 @@ import type { } from './protocol'; import { themeSnapshotToCSS, type ThemeSnapshot } from './host-theme'; import type { SlotName } from '../plugin-types'; -import { ContactCard } from '../jmap/types'; +import { AddressBook, ContactCard } from '../jmap/types'; import { EncryptionAtRestConfig, PublicKeyInput } from '@/stores/account-security-store'; // ─── Module-scope state ────────────────────────────────────── @@ -279,10 +279,16 @@ function buildPluginApi(manifest: PluginManifest) { ) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]), }, contacts: { - get: (contactId: string) => callApi('contact.get', [contactId]) as Promise, - update: (contactId: string, updates: Partial) => callApi('contact.update', [contactId, updates]), - create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise, + get: (contactId: string) => callApi('contact.get', [contactId]) as Promise, + update: (contactId: string, updates: Partial) => callApi('contact.update', [contactId, updates]) as Promise, + create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise, search: (query: string) => callApi('contact.search', [query]) as Promise, + list: (addressBookId?: string) => callApi('contact.list', [addressBookId]) as Promise, + remove: (contactId: string) => callApi('contact.delete', [contactId]) as Promise, + }, + addressBooks: { + list: () => callApi('addressbook.list', []) as Promise, + create: (name: string) => callApi('addressbook.create', [name]) as Promise, }, /** * Used to alterate files before they are uploaded to server. diff --git a/locales/ar/common.json b/locales/ar/common.json index 1b2131aba..e1c8f45c4 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -303,9 +303,7 @@ "move": "نقل", "print": "طباعة", "view_source": "عرض المصدر", - "fullscreen": "ملء الشاشة", - "exit_fullscreen": "الخروج من ملء الشاشة", "export_email": "تصدير كملف .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "نسخ رابط الحدث", "copied": "تم نسخ الرابط", "copy_failed": "تعذّر نسخ الرابط" + }, + "plugins": { + "oauth_callback": { + "completing": "جارٍ إكمال التفويض…", + "completing_message": "يرجى الانتظار بينما يتم إنشاء الاتصال.", + "received_title": "تم استلام التفويض", + "received_message": "يمكنك العودة إلى علامة التبويب التي بدأت فيها الاتصال.", + "failed_title": "فشل التفويض", + "denied": "تم رفض التفويض.", + "failed_detail": "فشل التفويض: {error}", + "missing_params": "رمز التفويض أو الحالة مفقود.", + "delivery_failed": "تعذّر إرسال نتيجة التفويض إلى تبويب التطبيق. يرجى إغلاق هذه النافذة والمحاولة مرة أخرى." + } } } diff --git a/locales/ca/common.json b/locales/ca/common.json index b6029b40d..bc41d46f7 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -303,9 +303,7 @@ "move": "Mou", "print": "Imprimeix", "view_source": "Mostra el codi font", - "fullscreen": "Pantalla completa", - "exit_fullscreen": "Surt de la pantalla completa", "export_email": "Exporta com a .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Copia l'enllaç de l'esdeveniment", "copied": "Enllaç copiat", "copy_failed": "No s'ha pogut copiar l'enllaç" + }, + "plugins": { + "oauth_callback": { + "completing": "Completant l'autorització…", + "completing_message": "Espereu mentre s'estableix la connexió.", + "received_title": "S'ha rebut l'autorització", + "received_message": "Podeu tornar a la pestanya on heu iniciat la connexió.", + "failed_title": "Ha fallat l'autorització", + "denied": "Sha denegat lautorització.", + "failed_detail": "Ha fallat lautorització: {error}", + "missing_params": "Falta el codi dautorització o lestat.", + "delivery_failed": "No sha pogut lliurar el resultat de lautorització a la pestanya de laplicació. Tanqueu aquesta finestra i torneu-ho a provar." + } } } diff --git a/locales/cs/common.json b/locales/cs/common.json index b4fb5c477..5074f2199 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -303,9 +303,7 @@ "move": "Přesunout", "print": "Tisk", "view_source": "Zobrazit zdrojový kód", - "fullscreen": "Celá obrazovka", - "exit_fullscreen": "Ukončit režim celé obrazovky", "export_email": "Exportovat jako .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Kopírovat odkaz na událost", "copied": "Odkaz zkopírován", "copy_failed": "Odkaz se nepodařilo zkopírovat" + }, + "plugins": { + "oauth_callback": { + "completing": "Dokončování autorizace…", + "completing_message": "Počkejte prosím, než se naváže spojení.", + "received_title": "Autorizace přijata", + "received_message": "Můžete se vrátit na kartu, kde jste zahájili připojení.", + "failed_title": "Autorizace se nezdařila", + "denied": "Autorizace byla zamítnuta.", + "failed_detail": "Autorizace se nezdařila: {error}", + "missing_params": "Chybí autorizační kód nebo stav.", + "delivery_failed": "Výsledek autorizace se nepodařilo předat kartě aplikace. Zavřete toto okno a zkuste to znovu." + } } } diff --git a/locales/da/common.json b/locales/da/common.json index bfb8a80cf..648f88fc7 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -303,9 +303,7 @@ "move": "Flyt", "print": "Udskriv", "view_source": "Vis kilde", - "fullscreen": "Fuld skærm", - "exit_fullscreen": "Afslut fuld skærm", "export_email": "Eksportér som .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Kopier link til begivenhed", "copied": "Link kopieret", "copy_failed": "Kunne ikke kopiere linket" + }, + "plugins": { + "oauth_callback": { + "completing": "Fuldfører godkendelse…", + "completing_message": "Vent venligst, mens forbindelsen oprettes.", + "received_title": "Godkendelse modtaget", + "received_message": "Du kan vende tilbage til den fane, hvor du startede forbindelsen.", + "failed_title": "Godkendelse mislykkedes", + "denied": "Godkendelse blev nægtet.", + "failed_detail": "Godkendelse mislykkedes: {error}", + "missing_params": "Autorisationskode eller tilstand mangler.", + "delivery_failed": "Resultatet af godkendelsen kunne ikke leveres til app-fanen. Luk dette vindue og prøv igen." + } } } diff --git a/locales/de/common.json b/locales/de/common.json index bee259d61..3c0836cce 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -303,9 +303,7 @@ "move": "Verschieben", "print": "Drucken", "view_source": "Quelltext anzeigen", - "fullscreen": "Vollbild", - "exit_fullscreen": "Vollbild beenden", "export_email": "Als .eml exportieren", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Link zum Termin kopieren", "copied": "Link kopiert", "copy_failed": "Link konnte nicht kopiert werden" + }, + "plugins": { + "oauth_callback": { + "completing": "Autorisierung wird abgeschlossen…", + "completing_message": "Bitte warten, während die Verbindung hergestellt wird.", + "received_title": "Autorisierung empfangen", + "received_message": "Sie können zu dem Tab zurückkehren, in dem Sie die Verbindung gestartet haben.", + "failed_title": "Autorisierung fehlgeschlagen", + "denied": "Autorisierung wurde verweigert.", + "failed_detail": "Autorisierung fehlgeschlagen: {error}", + "missing_params": "Autorisierungscode oder State fehlt.", + "delivery_failed": "Das Autorisierungsergebnis konnte nicht an den App-Tab übermittelt werden. Bitte schließen Sie dieses Fenster und versuchen Sie es erneut." + } } } diff --git a/locales/en/common.json b/locales/en/common.json index 10ba19cba..6084976c7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -303,9 +303,7 @@ "move": "Move", "print": "Print", "view_source": "View source", - "fullscreen": "Fullscreen", - "exit_fullscreen": "Exit fullscreen", "export_email": "Export as .eml", "forward_as_attachment": "Forward as attachment", @@ -1670,7 +1668,7 @@ "disable_tooltip": "Disable encryption", "enabled_success": "Encryption at rest enabled" }, - "public_keys":{ + "public_keys": { "name_label": "Public Key name", "name_placeholder": "e.g. My Key", "key_label": "Public Key (S/MIME or PGP ASCII-armored)", @@ -3541,5 +3539,18 @@ "copy_event": "Copy link to event", "copied": "Link copied", "copy_failed": "Couldn't copy the link" + }, + "plugins": { + "oauth_callback": { + "completing": "Completing authorization…", + "completing_message": "Please wait while the connection is established.", + "received_title": "Authorization received", + "received_message": "You can return to the tab where you started the connection.", + "failed_title": "Authorization failed", + "denied": "Authorization was denied.", + "failed_detail": "Authorization failed: {error}", + "missing_params": "Missing authorization code or state.", + "delivery_failed": "The authorization result could not be delivered to the app tab. Please close this window and try again." + } } } diff --git a/locales/es/common.json b/locales/es/common.json index 32aeffe4b..504aab1bc 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -303,9 +303,7 @@ "move": "Mover", "print": "Imprimir", "view_source": "Ver código fuente", - "fullscreen": "Pantalla completa", - "exit_fullscreen": "Salir de pantalla completa", "export_email": "Exportar como .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Copiar enlace del evento", "copied": "Enlace copiado", "copy_failed": "No se pudo copiar el enlace" + }, + "plugins": { + "oauth_callback": { + "completing": "Completando la autorización…", + "completing_message": "Por favor, espera mientras se establece la conexión.", + "received_title": "Autorización recibida", + "received_message": "Puedes volver a la pestaña donde iniciaste la conexión.", + "failed_title": "Error de autorización", + "denied": "Se denegó la autorización.", + "failed_detail": "Error de autorización: {error}", + "missing_params": "Falta el código de autorización o el estado.", + "delivery_failed": "No se pudo entregar el resultado de la autorización a la pestaña de la aplicación. Cierre esta ventana e inténtelo de nuevo." + } } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 4bba7084b..31b3bb449 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -303,9 +303,7 @@ "move": "انتقال", "print": "چاپ", "view_source": "مشاهده منبع", - "fullscreen": "تمامصفحه", - "exit_fullscreen": "خروج از تمامصفحه", "export_email": "خروجی .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "کپی پیوند رویداد", "copied": "پیوند کپی شد", "copy_failed": "کپی پیوند ممکن نشد" + }, + "plugins": { + "oauth_callback": { + "completing": "در حال تکمیل احراز هویت…", + "completing_message": "لطفاً تا برقراری اتصال منتظر بمانید.", + "received_title": "احراز هویت دریافت شد", + "received_message": "میتوانید به برگهای که اتصال را در آن آغاز کردید بازگردید.", + "failed_title": "احراز هویت ناموفق بود", + "denied": "احراز هویت رد شد.", + "failed_detail": "احراز هویت ناموفق بود: {error}", + "missing_params": "کد احراز هویت یا حالت وجود ندارد.", + "delivery_failed": "نتیجه احراز هویت به برگه برنامه ارسال نشد. لطفاً این پنجره را ببندید و دوباره تلاش کنید." + } } } diff --git a/locales/fr/common.json b/locales/fr/common.json index b91e136c7..506dcc56b 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -303,9 +303,7 @@ "move": "Déplacer", "print": "Imprimer", "view_source": "Voir la source", - "fullscreen": "Plein écran", - "exit_fullscreen": "Quitter le plein écran", "export_email": "Exporter en .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Copier le lien de l'événement", "copied": "Lien copié", "copy_failed": "Impossible de copier le lien" + }, + "plugins": { + "oauth_callback": { + "completing": "Finalisation de l'autorisation…", + "completing_message": "Veuillez patienter pendant l'établissement de la connexion.", + "received_title": "Autorisation reçue", + "received_message": "Vous pouvez revenir à l'onglet où vous avez commencé la connexion.", + "failed_title": "Échec de l'autorisation", + "denied": "Lautorisation a été refusée.", + "failed_detail": "Échec de lautorisation : {error}", + "missing_params": "Code dautorisation ou état manquant.", + "delivery_failed": "Le résultat de lautorisation na pas pu être transmis à longlet de lapplication. Fermez cette fenêtre et réessayez." + } } } diff --git a/locales/he/common.json b/locales/he/common.json index 33f67d331..c9d870c3d 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -250,9 +250,7 @@ "mark_read": "סמן כנקרא", "print": "הדפס", "view_source": "צפה במקור", - "fullscreen": "מסך מלא", - "exit_fullscreen": "יציאה ממסך מלא", "export_email": "ייצא כ-.eml", "forward_as_attachment": "Forward as attachment", @@ -3542,5 +3540,18 @@ "copy_event": "העתק קישור לאירוע", "copied": "הקישור הועתק", "copy_failed": "לא ניתן להעתיק את הקישור" + }, + "plugins": { + "oauth_callback": { + "completing": "משלים אימות…", + "completing_message": "אנא המתן בזמן יצירת החיבור.", + "received_title": "האימות התקבל", + "received_message": "באפשרותך לחזור לכרטיסייה שבה התחלת את החיבור.", + "failed_title": "האימות נכשל", + "denied": "האימות נדחה.", + "failed_detail": "האימות נכשל: {error}", + "missing_params": "חסר קוד אימות או מצב.", + "delivery_failed": "לא ניתן היה למסור את תוצאת האימות לכרטיסיית האפליקציה. סגרו חלון זה ונסו שוב." + } } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b2caaae19..88595ee30 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -303,9 +303,7 @@ "move": "Áthelyezés", "print": "Nyomtatás", "view_source": "Forrás megtekintése", - "fullscreen": "Teljes képernyő", - "exit_fullscreen": "Kilépés a teljes képernyőből", "export_email": "Exportálás .eml-ként", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Esemény linkjének másolása", "copied": "Link másolva", "copy_failed": "Nem sikerült másolni a linket" + }, + "plugins": { + "oauth_callback": { + "completing": "Hitelesítés befejezése…", + "completing_message": "Kérjük, várjon, amíg a kapcsolat létrejön.", + "received_title": "Hitelesítés fogadva", + "received_message": "Visszatérhet arra a lapra, ahol elindította a kapcsolódást.", + "failed_title": "A hitelesítés sikertelen", + "denied": "A hitelesítés megtagadva.", + "failed_detail": "A hitelesítés sikertelen: {error}", + "missing_params": "Hiányzik a hitelesítési kód vagy az állapot.", + "delivery_failed": "A hitelesítés eredményét nem sikerült átadni az alkalmazás lapjának. Zárja be ezt az ablakot, és próbálja újra." + } } } diff --git a/locales/it/common.json b/locales/it/common.json index 885d03edf..30f0f94b4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -303,9 +303,7 @@ "move": "Sposta", "print": "Stampa", "view_source": "Visualizza sorgente", - "fullscreen": "Schermo intero", - "exit_fullscreen": "Esci da schermo intero", "export_email": "Esporta come .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Copia link all'evento", "copied": "Link copiato", "copy_failed": "Impossibile copiare il link" + }, + "plugins": { + "oauth_callback": { + "completing": "Completamento dell'autorizzazione…", + "completing_message": "Attendi mentre viene stabilita la connessione.", + "received_title": "Autorizzazione ricevuta", + "received_message": "Puoi tornare alla scheda in cui hai avviato la connessione.", + "failed_title": "Autorizzazione non riuscita", + "denied": "Lautorizzazione è stata negata.", + "failed_detail": "Autorizzazione non riuscita: {error}", + "missing_params": "Codice di autorizzazione o stato mancante.", + "delivery_failed": "Impossibile consegnare il risultato dellautorizzazione alla scheda dellapp. Chiudi questa finestra e riprova." + } } } diff --git a/locales/ja/common.json b/locales/ja/common.json index e015c2886..09a831bd2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -303,9 +303,7 @@ "move": "移動", "print": "印刷", "view_source": "ソースを表示", - "fullscreen": "全画面表示", - "exit_fullscreen": "全画面表示を終了", "export_email": ".emlとしてエクスポート", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "予定のリンクをコピー", "copied": "リンクをコピーしました", "copy_failed": "リンクをコピーできませんでした" + }, + "plugins": { + "oauth_callback": { + "completing": "認証を完了しています…", + "completing_message": "接続が確立されるまでお待ちください。", + "received_title": "認証を受信しました", + "received_message": "接続を開始したタブに戻ることができます。", + "failed_title": "認証に失敗しました", + "denied": "認証が拒否されました。", + "failed_detail": "認証に失敗しました: {error}", + "missing_params": "認証コードまたはステートがありません。", + "delivery_failed": "認証結果をアプリのタブに渡せませんでした。このウィンドウを閉じて、もう一度お試しください。" + } } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 21c29b049..7d7707208 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -303,9 +303,7 @@ "move": "이동", "print": "인쇄", "view_source": "원본 보기", - "fullscreen": "전체 화면", - "exit_fullscreen": "전체 화면 종료", "export_email": ".eml 파일로 내보내기", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "일정 링크 복사", "copied": "링크가 복사되었습니다", "copy_failed": "링크를 복사하지 못했습니다" + }, + "plugins": { + "oauth_callback": { + "completing": "인증 완료 중…", + "completing_message": "연결이 설정되는 동안 잠시 기다려 주세요.", + "received_title": "인증 수신됨", + "received_message": "연결을 시작했던 탭으로 돌아갈 수 있습니다.", + "failed_title": "인증 실패", + "denied": "인증이 거부되었습니다.", + "failed_detail": "인증 실패: {error}", + "missing_params": "인증 코드 또는 상태가 누락되었습니다.", + "delivery_failed": "인증 결과를 앱 탭에 전달하지 못했습니다. 이 창을 닫고 다시 시도해 주세요." + } } } diff --git a/locales/lv/common.json b/locales/lv/common.json index cf0272b4e..1656d7040 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -303,9 +303,7 @@ "move": "Pārvietot", "print": "Drukāt", "view_source": "Skatīt avota kodu", - "fullscreen": "Pilnekrāna režīms", - "exit_fullscreen": "Iziet no pilnekrāna režīma", "export_email": "Eksportēt kā .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Kopēt saiti uz notikumu", "copied": "Saite nokopēta", "copy_failed": "Neizdevās nokopēt saiti" + }, + "plugins": { + "oauth_callback": { + "completing": "Pabeidz autorizāciju…", + "completing_message": "Lūdzu, uzgaidiet, kamēr tiek izveidots savienojums.", + "received_title": "Autorizācija saņemta", + "received_message": "Varat atgriezties cilnē, kurā sākāt savienojumu.", + "failed_title": "Autorizācija neizdevās", + "denied": "Autorizācija tika atteikta.", + "failed_detail": "Autorizācija neizdevās: {error}", + "missing_params": "Trūkst autorizācijas kods vai stāvoklis.", + "delivery_failed": "Autorizācijas rezultātu neizdevās nodot lietotnes cilnei. Aizveriet šo logu un mēģiniet vēlreiz." + } } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 10b08e758..1c3a8b0a1 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -303,9 +303,7 @@ "move": "Хөдөлгөөн", "print": "Хэвлэх", "view_source": "Эх сурвалжийг харах", - "fullscreen": "Бүтэн дэлгэц", - "exit_fullscreen": "Бүтэн дэлгэцээс гарах", "export_email": ".eml", "forward_as_attachment": "Хавсралт болгон дамжуулах", @@ -3541,5 +3539,18 @@ "copy_event": "Арга хэмжээний холбоосыг хуулах", "copied": "Холбоос хуулагдлаа", "copy_failed": "Холбоосыг хуулж чадсангүй" + }, + "plugins": { + "oauth_callback": { + "completing": "Зөвшөөрлийг дуусгаж байна…", + "completing_message": "Холболт үүсэх хүртэл түр хүлээнэ үү.", + "received_title": "Зөвшөөрөл хүлээн авсан", + "received_message": "Та холболт эхлүүлсэн таб руугаа буцаж болно.", + "failed_title": "Зөвшөөрөл амжилтгүй болсон", + "denied": "Зөвшөөрөл олгохыг татгалзсан.", + "failed_detail": "Зөвшөөрөл амжилтгүй болсон: {error}", + "missing_params": "Зөвшөөрлийн код эсвэл төлөв байхгүй байна.", + "delivery_failed": "Зөвшөөрлийн үр дүнг аппын таб руу дамжуулж чадсангүй. Энэ цонхыг хаагаад дахин оролдоно уу." + } } } diff --git a/locales/nb/common.json b/locales/nb/common.json index c7b9998fd..0f669ed47 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3539,5 +3539,18 @@ "copy_event": "Kopier lenken til hendelsen", "copied": "Lenken er kopiert", "copy_failed": "Kunne ikke kopiere lenken" + }, + "plugins": { + "oauth_callback": { + "completing": "Fullfører autorisering…", + "completing_message": "Vennligst vent mens tilkoblingen opprettes.", + "received_title": "Autorisering mottatt", + "received_message": "Du kan gå tilbake til fanen der du startet tilkoblingen.", + "failed_title": "Autorisering mislyktes", + "denied": "Autorisering ble avslått.", + "failed_detail": "Autorisering mislyktes: {error}", + "missing_params": "Mangler autorisasjonskode eller tilstand.", + "delivery_failed": "Autoriseringsresultatet kunne ikke leveres til app-fanen. Lukk dette vinduet og prøv igjen." + } } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 51f38ff9c..395427c4e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -303,9 +303,7 @@ "move": "Verplaatsen", "print": "Afdrukken", "view_source": "Bron bekijken", - "fullscreen": "Volledig scherm", - "exit_fullscreen": "Volledig scherm sluiten", "export_email": "Exporteren als .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Link naar afspraak kopiëren", "copied": "Link gekopieerd", "copy_failed": "Kon de link niet kopiëren" + }, + "plugins": { + "oauth_callback": { + "completing": "Autorisatie voltooien…", + "completing_message": "Even geduld terwijl de verbinding tot stand wordt gebracht.", + "received_title": "Autorisatie ontvangen", + "received_message": "Je kunt terugkeren naar het tabblad waar je de verbinding bent gestart.", + "failed_title": "Autorisatie mislukt", + "denied": "Autorisatie geweigerd.", + "failed_detail": "Autorisatie mislukt: {error}", + "missing_params": "Autorisatiecode of status ontbreekt.", + "delivery_failed": "Het autorisatieresultaat kon niet worden afgeleverd bij het app-tabblad. Sluit dit venster en probeer het opnieuw." + } } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 70de4d07c..0473d970d 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -303,9 +303,7 @@ "move": "Przenieś", "print": "Drukuj", "view_source": "Pokaż źródło", - "fullscreen": "Pełny ekran", - "exit_fullscreen": "Zamknij pełny ekran", "export_email": "Eksportuj jako .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Kopiuj link do wydarzenia", "copied": "Link skopiowany", "copy_failed": "Nie udało się skopiować linku" + }, + "plugins": { + "oauth_callback": { + "completing": "Kończenie autoryzacji…", + "completing_message": "Proszę czekać na nawiązanie połączenia.", + "received_title": "Otrzymano autoryzację", + "received_message": "Możesz wrócić do karty, w której rozpoczęto nawiązywanie połączenia.", + "failed_title": "Autoryzacja nie powiodła się", + "denied": "Autoryzacja została odrzucona.", + "failed_detail": "Autoryzacja nie powiodła się: {error}", + "missing_params": "Brak kodu autoryzacji lub stanu.", + "delivery_failed": "Nie udało się przekazać wyniku autoryzacji do karty aplikacji. Zamknij to okno i spróbuj ponownie." + } } } diff --git a/locales/pt/common.json b/locales/pt/common.json index aa13e3d4b..68b131064 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -303,9 +303,7 @@ "move": "Mover", "print": "Imprimir", "view_source": "Ver código-fonte", - "fullscreen": "Tela cheia", - "exit_fullscreen": "Sair da tela cheia", "export_email": "Exportar como .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Copiar link do evento", "copied": "Link copiado", "copy_failed": "Não foi possível copiar o link" + }, + "plugins": { + "oauth_callback": { + "completing": "A concluir a autorização…", + "completing_message": "Aguarde enquanto a ligação é estabelecida.", + "received_title": "Autorização recebida", + "received_message": "Pode voltar ao separador onde iniciou a ligação.", + "failed_title": "Falha na autorização", + "denied": "A autorização foi negada.", + "failed_detail": "Falha na autorização: {error}", + "missing_params": "Falta o código de autorização ou o estado.", + "delivery_failed": "Não foi possível entregar o resultado da autorização ao separador da aplicação. Feche esta janela e tente novamente." + } } } diff --git a/locales/ro/common.json b/locales/ro/common.json index ae3002660..5bea04169 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -303,9 +303,7 @@ "move": "Mutare", "print": "Imprimare", "view_source": "Vizualizați sursa", - "fullscreen": "Ecran complet", - "exit_fullscreen": "Ieșire din ecran complet", "export_email": "Exportați ca fișier .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Copiați linkul evenimentului", "copied": "Link copiat", "copy_failed": "Linkul nu a putut fi copiat" + }, + "plugins": { + "oauth_callback": { + "completing": "Se finalizează autorizarea…", + "completing_message": "Vă rugăm să așteptați stabilirea conexiunii.", + "received_title": "Autorizare primită", + "received_message": "Vă puteți întoarce la fila în care ați inițiat conexiunea.", + "failed_title": "Autorizarea a eșuat", + "denied": "Autorizarea a fost refuzată.", + "failed_detail": "Autorizarea a eșuat: {error}", + "missing_params": "Lipsește codul de autorizare sau starea.", + "delivery_failed": "Rezultatul autorizării nu a putut fi transmis filei aplicației. Închideți această fereastră și încercați din nou." + } } } diff --git a/locales/ru/common.json b/locales/ru/common.json index e59452e39..2a3210e5f 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -303,9 +303,7 @@ "move": "Переместить", "print": "Распечатать", "view_source": "Просмотреть исходный код", - "fullscreen": "Во весь экран", - "exit_fullscreen": "Выйти из полноэкранного режима", "export_email": "Экспортировать как .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Скопировать ссылку на событие", "copied": "Ссылка скопирована", "copy_failed": "Не удалось скопировать ссылку" + }, + "plugins": { + "oauth_callback": { + "completing": "Завершение авторизации…", + "completing_message": "Пожалуйста, подождите, пока устанавливается соединение.", + "received_title": "Авторизация получена", + "received_message": "Вы можете вернуться на вкладку, где начали подключение.", + "failed_title": "Ошибка авторизации", + "denied": "В авторизации отказано.", + "failed_detail": "Ошибка авторизации: {error}", + "missing_params": "Отсутствует код авторизации или состояние.", + "delivery_failed": "Не удалось передать результат авторизации на вкладку приложения. Закройте это окно и попробуйте снова." + } } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 18cb177ab..f9c70c57b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -303,9 +303,7 @@ "move": "Presunúť", "print": "Tlačiť", "view_source": "Zobraziť zdrojový kód", - "fullscreen": "Celá obrazovka", - "exit_fullscreen": "Ukončiť režim celej obrazovky", "export_email": "Exportovať ako .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Kopírovať odkaz na udalosť", "copied": "Odkaz skopírovaný", "copy_failed": "Odkaz sa nepodarilo skopírovať" + }, + "plugins": { + "oauth_callback": { + "completing": "Dokončovanie autorizácie…", + "completing_message": "Počkajte, kým sa vytvorí spojenie.", + "received_title": "Autorizácia prijatá", + "received_message": "Môžete sa vrátiť na kartu, kde ste začali pripájanie.", + "failed_title": "Autorizácia zlyhala", + "denied": "Autorizácia bola zamietnutá.", + "failed_detail": "Autorizácia zlyhala: {error}", + "missing_params": "Chýba autorizačný kód alebo stav.", + "delivery_failed": "Výsledok autorizácie sa nepodarilo odovzdať karte aplikácie. Zavrite toto okno a skúste to znova." + } } } diff --git a/locales/tr/common.json b/locales/tr/common.json index bd371c36a..6d98cf1c4 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -303,9 +303,7 @@ "move": "Taşı", "print": "Yazdır", "view_source": "Kaynağı görüntüle", - "fullscreen": "Tam ekran", - "exit_fullscreen": "Tam ekrandan çık", "export_email": ".eml olarak dışa aktar", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Etkinlik bağlantısını kopyala", "copied": "Bağlantı kopyalandı", "copy_failed": "Bağlantı kopyalanamadı" + }, + "plugins": { + "oauth_callback": { + "completing": "Yetkilendirme tamamlanıyor…", + "completing_message": "Bağlantı kurulurken lütfen bekleyin.", + "received_title": "Yetkilendirme alındı", + "received_message": "Bağlantıyı başlattığınız sekmeye dönebilirsiniz.", + "failed_title": "Yetkilendirme başarısız oldu", + "denied": "Yetkilendirme reddedildi.", + "failed_detail": "Yetkilendirme başarısız oldu: {error}", + "missing_params": "Yetkilendirme kodu veya durum eksik.", + "delivery_failed": "Yetkilendirme sonucu uygulama sekmesine iletilemedi. Lütfen bu pencereyi kapatıp tekrar deneyin." + } } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 37866f534..fb8cfebfa 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -303,9 +303,7 @@ "move": "Перенести", "print": "Роздрукувати", "view_source": "Переглянути джерело", - "fullscreen": "На весь екран", - "exit_fullscreen": "Вийти з повноекранного режиму", "export_email": "Експортувати як .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "Скопіювати посилання на подію", "copied": "Посилання скопійовано", "copy_failed": "Не вдалося скопіювати посилання" + }, + "plugins": { + "oauth_callback": { + "completing": "Завершення авторизації…", + "completing_message": "Зачекайте, поки встановлюється з'єднання.", + "received_title": "Авторизацію отримано", + "received_message": "Ви можете повернутися на вкладку, де розпочали підключення.", + "failed_title": "Не вдалося виконати авторизацію", + "denied": "В авторизації відмовлено.", + "failed_detail": "Не вдалося виконати авторизацію: {error}", + "missing_params": "Відсутній код авторизації або стан.", + "delivery_failed": "Не вдалося передати результат авторизації на вкладку програми. Закрийте це вікно та спробуйте ще раз." + } } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index ac0516542..36d8ce6d5 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3534,5 +3534,18 @@ "copy_event": "複製活動連結", "copied": "連結已複製", "copy_failed": "無法複製連結" + }, + "plugins": { + "oauth_callback": { + "completing": "正在完成授權…", + "completing_message": "請稍候,正在建立連線。", + "received_title": "已收到授權", + "received_message": "您可以返回開始連線的分頁。", + "failed_title": "授權失敗", + "denied": "授權被拒絕。", + "failed_detail": "授權失敗:{error}", + "missing_params": "缺少授權碼或狀態。", + "delivery_failed": "無法將授權結果傳遞至應用程式分頁。請關閉此視窗並再試一次。" + } } } diff --git a/locales/zh/common.json b/locales/zh/common.json index cc88bbb11..c08f8b377 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -303,9 +303,7 @@ "move": "移动", "print": "打印", "view_source": "查看源码", - "fullscreen": "全屏", - "exit_fullscreen": "退出全屏", "export_email": "导出为 .eml", "forward_as_attachment": "Forward as attachment", @@ -3541,5 +3539,18 @@ "copy_event": "复制日程链接", "copied": "链接已复制", "copy_failed": "无法复制链接" + }, + "plugins": { + "oauth_callback": { + "completing": "正在完成授权…", + "completing_message": "正在建立连接,请稍候。", + "received_title": "已收到授权", + "received_message": "您可以返回发起连接的标签页。", + "failed_title": "授权失败", + "denied": "授权被拒绝。", + "failed_detail": "授权失败:{error}", + "missing_params": "缺少授权码或状态。", + "delivery_failed": "无法将授权结果传递到应用标签页。请关闭此窗口并重试。" + } } }
{title}
+ {message} +