mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
Merge pull request #925 from ponchofiesta/feature/oauth-callback-handler-cleaned
OAuth handler for plugins; extended contacts and addressbook API
This commit is contained in:
@@ -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}
|
||||
<PluginDialogHost />
|
||||
<PluginConsentDialog />
|
||||
<PluginOAuthCallbackListener />
|
||||
<PWAInstallPrompt />
|
||||
<PushNotificationPrompt />
|
||||
</ProtocolLaunchHandlerProvider>
|
||||
|
||||
@@ -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<string | null>(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 (
|
||||
<OAuthStatusCard
|
||||
icon={
|
||||
status === "ok"
|
||||
? CheckCircle2
|
||||
: status === "error"
|
||||
? XCircle
|
||||
: undefined
|
||||
}
|
||||
iconClassName={
|
||||
status === "ok"
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400"
|
||||
: status === "error"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: undefined
|
||||
}
|
||||
title={
|
||||
status === "waiting"
|
||||
? t("oauth_callback.completing")
|
||||
: status === "ok"
|
||||
? t("oauth_callback.received_title")
|
||||
: t("oauth_callback.failed_title")
|
||||
}
|
||||
message={
|
||||
status === "waiting"
|
||||
? t("oauth_callback.completing_message")
|
||||
: status === "ok"
|
||||
? t("oauth_callback.received_message")
|
||||
: (errorMessage ?? undefined)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginOAuthCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<OAuthStatusCard title="" />}>
|
||||
<PluginOAuthCallbackInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
let disposable: { dispose: () => void };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockUsePathname.mockReturnValue("/en/mail");
|
||||
handler = vi.fn<(payload: unknown) => Promise<void>>().mockResolvedValue(undefined);
|
||||
disposable = authHooks.onOAuthCallback.register("test-plugin", handler);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposable.dispose();
|
||||
});
|
||||
|
||||
it("delivers a success payload via the storage event", () => {
|
||||
const { unmount } = render(<PluginOAuthCallbackListener />);
|
||||
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(<PluginOAuthCallbackListener />);
|
||||
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(<PluginOAuthCallbackListener />);
|
||||
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(<PluginOAuthCallbackListener />);
|
||||
|
||||
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(<PluginOAuthCallbackListener />);
|
||||
|
||||
// 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(<PluginOAuthCallbackListener />);
|
||||
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(<PluginOAuthCallbackListener />);
|
||||
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(<PluginOAuthCallbackListener />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="min-h-dvh flex flex-col items-center justify-center bg-linear-to-br from-background via-background to-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-100 mx-auto">
|
||||
<div className="rounded-2xl border border-border/60 bg-background/80 backdrop-blur-sm shadow-xl shadow-black/5 dark:shadow-black/20 overflow-hidden">
|
||||
{/* Header with instance branding */}
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground tracking-tight">
|
||||
{appName}
|
||||
</h1>
|
||||
|
||||
{/* Status badge */}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-6 inline-flex items-center justify-center w-14 h-14 rounded-2xl",
|
||||
iconClassName ?? "bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className="w-7 h-7" />
|
||||
) : (
|
||||
<Loader2 className="w-7 h-7 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status body */}
|
||||
<div className="px-8 pb-10 pt-0 text-center">
|
||||
<p className="text-base font-medium text-foreground">{title}</p>
|
||||
{message && (
|
||||
<p className="text-sm text-muted-foreground mt-1.5 leading-relaxed">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
{action && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-6"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
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<OAuthCallbackPayload>).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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<string, Permission | null> = {
|
||||
'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<ContactCard> {
|
||||
return await client.createContact(contact);
|
||||
}
|
||||
|
||||
async function doContactList(addressBookId?: string): Promise<ContactCard[]> {
|
||||
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<void> {
|
||||
const { client } = useAuthStore.getState();
|
||||
if (!client) {
|
||||
throw new Error('contact.delete: no active session');
|
||||
}
|
||||
await client.deleteContact(contactId);
|
||||
}
|
||||
|
||||
async function doAddressBookList(): Promise<AddressBook[]> {
|
||||
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<AddressBook> {
|
||||
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<ContactCard>);
|
||||
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();
|
||||
|
||||
@@ -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<ContactCard>,
|
||||
update: (contactId: string, updates: Partial<ContactCard>) => callApi('contact.update', [contactId, updates]),
|
||||
create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise<string>,
|
||||
get: (contactId: string) => callApi('contact.get', [contactId]) as Promise<ContactCard | null>,
|
||||
update: (contactId: string, updates: Partial<ContactCard>) => callApi('contact.update', [contactId, updates]) as Promise<void>,
|
||||
create: (contact: ContactCard) => callApi('contact.create', [contact]) as Promise<ContactCard>,
|
||||
search: (query: string) => callApi('contact.search', [query]) as Promise<ContactCard[]>,
|
||||
list: (addressBookId?: string) => callApi('contact.list', [addressBookId]) as Promise<ContactCard[]>,
|
||||
remove: (contactId: string) => callApi('contact.delete', [contactId]) as Promise<void>,
|
||||
},
|
||||
addressBooks: {
|
||||
list: () => callApi('addressbook.list', []) as Promise<AddressBook[]>,
|
||||
create: (name: string) => callApi('addressbook.create', [name]) as Promise<AddressBook>,
|
||||
},
|
||||
/**
|
||||
* Used to alterate files before they are uploaded to server.
|
||||
|
||||
+13
-2
@@ -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": "تعذّر إرسال نتيجة التفويض إلى تبويب التطبيق. يرجى إغلاق هذه النافذة والمحاولة مرة أخرى."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-3
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "نتیجه احراز هویت به برگه برنامه ارسال نشد. لطفاً این پنجره را ببندید و دوباره تلاش کنید."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "לא ניתן היה למסור את תוצאת האימות לכרטיסיית האפליקציה. סגרו חלון זה ונסו שוב."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "認証結果をアプリのタブに渡せませんでした。このウィンドウを閉じて、もう一度お試しください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "인증 결과를 앱 탭에 전달하지 못했습니다. 이 창을 닫고 다시 시도해 주세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "Зөвшөөрлийн үр дүнг аппын таб руу дамжуулж чадсангүй. Энэ цонхыг хаагаад дахин оролдоно уу."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "Не удалось передать результат авторизации на вкладку приложения. Закройте это окно и попробуйте снова."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "Не вдалося передати результат авторизації на вкладку програми. Закрийте це вікно та спробуйте ще раз."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "無法將授權結果傳遞至應用程式分頁。請關閉此視窗並再試一次。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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": "无法将授权结果传递到应用标签页。请关闭此窗口并重试。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user