diff --git a/main.js b/main.js index 6346f0d83..f8dca3fe1 100644 --- a/main.js +++ b/main.js @@ -463,6 +463,11 @@ app.on("open-url", (event, url) => { return; } + if (url.includes("/invitations/")) { + handleInvitationDeepLink(url); + return; + } + void handleOAuthDeepLink(url); if (windowManager && isLiveWindow(windowManager.controlPanelWindow)) { @@ -471,6 +476,30 @@ app.on("open-url", (event, url) => { } }); +function handleInvitationDeepLink(deepLinkUrl) { + try { + const match = deepLinkUrl.match(/invitations\/([^/?#]+)/); + const token = match?.[1]; + if (!token) return; + if (windowManager && isLiveWindow(windowManager.controlPanelWindow)) { + windowManager.controlPanelWindow.show(); + windowManager.controlPanelWindow.focus(); + windowManager.controlPanelWindow.webContents.send("workspace-invitation-token", token); + } else if (windowManager) { + windowManager.createControlPanelWindow(); + // Defer the send until renderer is ready; main.js relies on `did-finish-load` + const win = windowManager.controlPanelWindow; + if (win) { + win.webContents.once("did-finish-load", () => { + win.webContents.send("workspace-invitation-token", token); + }); + } + } + } catch (error) { + console.error("Invitation deep link parse failed:", error); + } +} + function resolveAuthUrl() { const fs = require("fs"); const envPath = path.join(__dirname, "src", "dist", "runtime-env.json"); @@ -1452,6 +1481,8 @@ if (gotSingleInstanceLock) { if (url) { if (url.includes("upgrade-success")) { handleUpgradeDeepLink(); + } else if (url.includes("/invitations/")) { + handleInvitationDeepLink(url); } else { void handleOAuthDeepLink(url); } diff --git a/preload.js b/preload.js index e2c7aab8e..f4b8714d5 100644 --- a/preload.js +++ b/preload.js @@ -600,6 +600,12 @@ contextBridge.exposeInMainWorld("electronAPI", { notifyLimitReached: (data) => ipcRenderer.send("limit-reached", data), onLimitReached: registerListener("limit-reached", (callback) => (_event, data) => callback(data)), + // Workspace invitation deep link + onWorkspaceInvitationToken: registerListener( + "workspace-invitation-token", + (callback) => (_event, token) => callback(token) + ), + // Globe key listener for hotkey capture (macOS only) onGlobeKeyPressed: (callback) => { const listener = () => callback?.(); diff --git a/src/components/AcceptInvitationModal.tsx b/src/components/AcceptInvitationModal.tsx new file mode 100644 index 000000000..ea19ab201 --- /dev/null +++ b/src/components/AcceptInvitationModal.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { InvitationsService } from "../services/InvitationsService"; +import { useWorkspaceStore } from "../stores/workspaceStore"; +import { useToast } from "./ui/useToast"; +import type { InvitationPreview } from "../types/electron"; + +interface Props { + token: string | null; + onClose: () => void; + isSignedIn: boolean; + onSignIn: () => void; +} + +const STORAGE_KEY = "pendingInvitationToken"; + +export default function AcceptInvitationModal({ token, onClose, isSignedIn, onSignIn }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const refresh = useWorkspaceStore((s) => s.refresh); + const setActive = useWorkspaceStore((s) => s.setActiveWorkspaceId); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [accepting, setAccepting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!token) { + setPreview(null); + setError(null); + return; + } + setLoading(true); + InvitationsService.preview(token) + .then(setPreview) + .catch((err) => setError(err instanceof Error ? err.message : t("common.unknownError"))) + .finally(() => setLoading(false)); + }, [token, t]); + + async function handleAccept() { + if (!token) return; + if (!isSignedIn) { + localStorage.setItem(STORAGE_KEY, token); + onSignIn(); + return; + } + setAccepting(true); + try { + const result = await InvitationsService.accept(token); + localStorage.removeItem(STORAGE_KEY); + await refresh(); + setActive(result.workspace_id); + toast({ + title: t("workspaces.accept.successTitle"), + description: preview ? t("workspaces.accept.successDescription", { name: preview.workspace_name }) : undefined, + }); + onClose(); + } catch (err) { + toast({ + title: t("workspaces.accept.errorTitle"), + description: err instanceof Error ? err.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setAccepting(false); + } + } + + function handleDecline() { + if (token) localStorage.removeItem(STORAGE_KEY); + onClose(); + } + + return ( + !open && handleDecline()}> + + + {t("workspaces.accept.title")} + {preview && ( + + {t("workspaces.accept.description", { + inviter: preview.inviter_name || preview.inviter_email || "", + workspace: preview.workspace_name, + role: preview.workspace_role, + })} + + )} + {error && {error}} + {loading && ( + {t("workspaces.accept.loading")} + )} + + + + + + + + ); +} + +export function consumePendingInvitationToken(): string | null { + if (typeof window === "undefined") return null; + const token = localStorage.getItem(STORAGE_KEY); + return token; +} + +export function clearPendingInvitationToken(): void { + if (typeof window === "undefined") return; + localStorage.removeItem(STORAGE_KEY); +} diff --git a/src/components/ControlPanel.tsx b/src/components/ControlPanel.tsx index fd0ef3795..e65a5e4b9 100644 --- a/src/components/ControlPanel.tsx +++ b/src/components/ControlPanel.tsx @@ -42,6 +42,10 @@ import { fetchProviders as fetchStreamingProviders } from "../stores/streamingPr import HistoryView from "./HistoryView"; import BackgroundActionToastListener from "./notes/BackgroundActionToastListener"; import { syncService } from "../services/SyncService.js"; +import AcceptInvitationModal, { + consumePendingInvitationToken, + clearPendingInvitationToken, +} from "./AcceptInvitationModal"; const platform = getCachedPlatform(); @@ -68,6 +72,7 @@ export default function ControlPanel() { () => localStorage.getItem("aiCTADismissed") === "true" ); const [showReferrals, setShowReferrals] = useState(false); + const [invitationToken, setInvitationToken] = useState(null); const [showSearch, setShowSearch] = useState(false); const [showCloudMigrationBanner, setShowCloudMigrationBanner] = useState(false); const [activeView, setActiveView] = useState("home"); @@ -238,6 +243,22 @@ export default function ControlPanel() { }); }, [usage?.isPastDue, usage?.hasLoaded, toast, t]); + useEffect(() => { + const unsubscribe = window.electronAPI?.onWorkspaceInvitationToken?.((token) => { + setInvitationToken(token); + }); + return () => unsubscribe?.(); + }, []); + + useEffect(() => { + if (!authLoaded || !isSignedIn) return; + const pending = consumePendingInvitationToken(); + if (pending) { + setInvitationToken(pending); + clearPendingInvitationToken(); + } + }, [authLoaded, isSignedIn]); + useEffect(() => { if (!authLoaded || !isSignedIn || cloudMigrationProcessed.current) return; const isPending = localStorage.getItem("pendingCloudMigration") === "true"; @@ -653,6 +674,15 @@ export default function ControlPanel() { )} + setInvitationToken(null)} + isSignedIn={isSignedIn} + onSignIn={() => { + setInvitationToken(null); + }} + /> + {showSearch && ( localStorage.getItem("upgradeProDismissed") === "true" ); + const [inviteOpen, setInviteOpen] = useState(false); + const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false); + const { active: activeWorkspace } = useWorkspace(); const showLimitBanner = authLoaded && isSignedIn && !isProUser && isOverLimit; const showUpgradeBanner = @@ -97,6 +105,12 @@ export default function ControlPanelSidebar({ style={{ WebkitAppRegion: "drag" } as React.CSSProperties} /> + {isSignedIn && ( +
+ +
+ )} + {onOpenSearch && (
+ )} +
+ + {activeWorkspace && ( + + )} + ); } diff --git a/src/components/CreateWorkspaceDialog.tsx b/src/components/CreateWorkspaceDialog.tsx new file mode 100644 index 000000000..056062170 --- /dev/null +++ b/src/components/CreateWorkspaceDialog.tsx @@ -0,0 +1,94 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { useWorkspaceStore } from "../stores/workspaceStore"; +import { useToast } from "./ui/useToast"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated?: (workspaceId: string) => void; +} + +export default function CreateWorkspaceDialog({ open, onOpenChange, onCreated }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const createWorkspace = useWorkspaceStore((s) => s.createWorkspace); + const setActive = useWorkspaceStore((s) => s.setActiveWorkspaceId); + const [name, setName] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + setSubmitting(true); + try { + const workspace = await createWorkspace(name.trim()); + setActive(workspace.id); + onCreated?.(workspace.id); + onOpenChange(false); + setName(""); + toast({ + title: t("workspaces.created.title"), + description: t("workspaces.created.description", { name: workspace.name }), + }); + } catch (error) { + toast({ + title: t("workspaces.create.errorTitle"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + } + + return ( + + + + {t("workspaces.create.title")} + {t("workspaces.create.description")} + +
+
+ + setName(e.target.value)} + placeholder={t("workspaces.create.namePlaceholder")} + maxLength={80} + /> +
+ + + + +
+
+
+ ); +} diff --git a/src/components/InviteTeammateDialog.tsx b/src/components/InviteTeammateDialog.tsx new file mode 100644 index 000000000..8e18b5faf --- /dev/null +++ b/src/components/InviteTeammateDialog.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "./ui/dialog"; +import { Button } from "./ui/button"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { cn } from "./lib/utils"; +import { useWorkspaceStore } from "../stores/workspaceStore"; +import { InvitationsService } from "../services/InvitationsService"; +import { TeamsService } from "../services/TeamsService"; +import { useToast } from "./ui/useToast"; +import type { Team } from "../types/electron"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string; + workspaceName: string; + onInvited?: () => void; +} + +export default function InviteTeammateDialog({ + open, + onOpenChange, + workspaceId, + workspaceName, + onInvited, +}: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"admin" | "member">("member"); + const [teams, setTeams] = useState([]); + const [selectedTeams, setSelectedTeams] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + const refreshTeams = useWorkspaceStore((s) => s.refreshTeams); + + useEffect(() => { + if (!open) return; + TeamsService.list(workspaceId) + .then(setTeams) + .catch(() => setTeams([])); + }, [open, workspaceId]); + + useEffect(() => { + if (!open) { + setEmail(""); + setRole("member"); + setSelectedTeams(new Set()); + } + }, [open]); + + function toggleTeam(id: string) { + setSelectedTeams((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!email.trim()) return; + setSubmitting(true); + try { + await InvitationsService.send(workspaceId, { + email: email.trim().toLowerCase(), + role, + team_ids: Array.from(selectedTeams), + }); + toast({ + title: t("workspaces.invite.sentTitle"), + description: t("workspaces.invite.sentDescription", { email }), + }); + void refreshTeams(workspaceId); + onInvited?.(); + onOpenChange(false); + } catch (error) { + toast({ + title: t("workspaces.invite.errorTitle"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + } + + return ( + + + + + {t("workspaces.invite.title", { workspace: workspaceName })} + + {t("workspaces.invite.description")} + +
+
+ + setEmail(e.target.value)} + placeholder="teammate@example.com" + required + /> +
+ +
+ +
+ {(["member", "admin"] as const).map((r) => ( + + ))} +
+
+ + {teams.length > 0 && ( +
+ +
+ {teams.map((team) => { + const checked = selectedTeams.has(team.id); + return ( + + ); + })} +
+
+ )} + + + + + +
+
+
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 974610642..14cda8589 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -9,6 +9,7 @@ import { Keyboard, CreditCard, Shield, + Users, } from "lucide-react"; import SidebarModal, { type SidebarItem } from "./ui/SidebarModal"; import SettingsPage, { SettingsSectionType } from "./SettingsPage"; @@ -66,6 +67,13 @@ export default function SettingsModal({ open, onOpenChange, initialSection }: Se description: t("settingsModal.sections.plansBilling.description"), group: t("settingsModal.groups.account"), }, + { + id: "workspace", + label: t("settingsModal.sections.workspace.label"), + icon: Users, + description: t("settingsModal.sections.workspace.description"), + group: t("settingsModal.groups.account"), + }, { id: "general", label: t("settingsModal.sections.general.label"), diff --git a/src/components/SettingsPage.tsx b/src/components/SettingsPage.tsx index bad8ed258..f6f912a7a 100644 --- a/src/components/SettingsPage.tsx +++ b/src/components/SettingsPage.tsx @@ -96,6 +96,7 @@ import { syncService } from "../services/SyncService.js"; import { formatBytes } from "../utils/formatBytes"; import { useSettingsStore } from "../stores/settingsStore"; import { canManageSystemAudioInApp } from "../utils/systemAudioAccess"; +import WorkspaceSection from "./settings/WorkspaceSection"; const formatAmount = (cents: number, currency: string) => (cents / 100).toLocaleString(undefined, { style: "currency", currency }); @@ -103,6 +104,7 @@ const formatAmount = (cents: number, currency: string) => export type SettingsSectionType = | "account" | "plansBilling" + | "workspace" | "general" | "hotkeys" | "speechToText" @@ -2246,6 +2248,9 @@ export default function SettingsPage({ ); + case "workspace": + return ; + case "general": return (
diff --git a/src/components/WorkspaceSwitcher.tsx b/src/components/WorkspaceSwitcher.tsx new file mode 100644 index 000000000..63f154bea --- /dev/null +++ b/src/components/WorkspaceSwitcher.tsx @@ -0,0 +1,108 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronsUpDown, Plus, Check } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "./ui/dropdown-menu"; +import { useWorkspace } from "../hooks/useWorkspace"; +import { cn } from "./lib/utils"; +import CreateWorkspaceDialog from "./CreateWorkspaceDialog"; + +function workspaceInitials(name: string): string { + return name + .trim() + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase(); +} + +export default function WorkspaceSwitcher({ userName }: { userName?: string | null }) { + const { t } = useTranslation(); + const { workspaces, active, setActive } = useWorkspace(); + const [createOpen, setCreateOpen] = useState(false); + + const label = active ? active.name : t("workspaces.switcher.personal"); + const initials = active + ? workspaceInitials(active.name) + : (userName?.[0]?.toUpperCase() ?? "P"); + + return ( + <> + + + + {initials} + + {label} + + + + + {t("workspaces.switcher.workspaces")} + + {workspaces.length === 0 && ( +
+ {t("workspaces.switcher.empty")} +
+ )} + {workspaces.map((ws) => { + const isActive = active?.id === ws.id; + return ( + setActive(ws.id)} + className="gap-2 text-xs" + > + + {workspaceInitials(ws.name)} + + {ws.name} + {isActive && } + + ); + })} + + setActive(null)} className="gap-2 text-xs"> + + {userName?.[0]?.toUpperCase() ?? "P"} + + {t("workspaces.switcher.personal")} + {!active && } + + + setCreateOpen(true)} className="gap-2 text-xs"> + + {t("workspaces.switcher.create")} + +
+
+ + + + ); +} diff --git a/src/components/settings/WorkspaceBillingTab.tsx b/src/components/settings/WorkspaceBillingTab.tsx new file mode 100644 index 000000000..2638dbc80 --- /dev/null +++ b/src/components/settings/WorkspaceBillingTab.tsx @@ -0,0 +1,141 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { ExternalLink } from "lucide-react"; +import { Button } from "../ui/button"; +import { useToast } from "../ui/useToast"; +import { WorkspacesService } from "../../services/WorkspacesService"; +import { useWorkspaceStore } from "../../stores/workspaceStore"; +import type { Workspace } from "../../types/electron"; + +interface Props { + workspace: Workspace; +} + +const PLAN_LABELS: Record = { + business: "Business", + pro: "Pro", + free: "Free", +}; + +export default function WorkspaceBillingTab({ workspace }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const members = useWorkspaceStore((s) => s.members); + const isOwner = workspace.role === "owner"; + const [busy, setBusy] = useState(false); + + const seatsUsed = members.length; + const seatsTotal = Math.max(workspace.seats, seatsUsed); + const pct = seatsTotal === 0 ? 0 : Math.min(100, (seatsUsed / seatsTotal) * 100); + + async function handleCheckout() { + setBusy(true); + try { + const url = await WorkspacesService.billingCheckout(workspace.id, "monthly"); + window.electronAPI?.openExternal?.(url) ?? window.open(url, "_blank"); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setBusy(false); + } + } + + async function handlePortal() { + setBusy(true); + try { + const url = await WorkspacesService.billingPortal(workspace.id); + window.electronAPI?.openExternal?.(url) ?? window.open(url, "_blank"); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setBusy(false); + } + } + + return ( +
+
+

+ {t("settingsPage.workspace.billing.title")} +

+

+ {t("settingsPage.workspace.billing.description")} +

+
+ +
+
+
+

+ {t("settingsPage.workspace.billing.plan")} +

+

+ {PLAN_LABELS[workspace.plan] || workspace.plan} +

+
+ + {workspace.status} + +
+ +
+
+ + {t("settingsPage.workspace.billing.seats")} + + + {seatsUsed} / {seatsTotal} + +
+
+
+
+
+ + {workspace.current_period_end && ( +
+ {t("settingsPage.workspace.billing.nextInvoice")} + + {new Date(workspace.current_period_end).toLocaleDateString()} + +
+ )} +
+ + {isOwner && ( +
+ {workspace.stripe_subscription_id ? ( + + ) : ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/settings/WorkspaceDeveloperTab.tsx b/src/components/settings/WorkspaceDeveloperTab.tsx new file mode 100644 index 000000000..385a6dba5 --- /dev/null +++ b/src/components/settings/WorkspaceDeveloperTab.tsx @@ -0,0 +1,301 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Copy, Trash2, Check, Key } from "lucide-react"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { useToast } from "../ui/useToast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../ui/dialog"; +import { WorkspaceApiKeysService } from "../../services/WorkspaceApiKeysService"; +import type { Workspace, WorkspaceApiKey, NewWorkspaceApiKey } from "../../types/electron"; +import { cn } from "../lib/utils"; + +interface Props { + workspace: Workspace; +} + +const SCOPE_GROUPS: { title: string; scopes: { id: string; label: string }[] }[] = [ + { + title: "Notes", + scopes: [ + { id: "workspace:notes:read", label: "Read notes" }, + { id: "workspace:notes:write", label: "Write notes" }, + ], + }, + { + title: "Folders", + scopes: [ + { id: "workspace:folders:read", label: "Read folders" }, + { id: "workspace:folders:write", label: "Write folders" }, + ], + }, + { + title: "Transcriptions", + scopes: [{ id: "workspace:transcriptions:read", label: "Read transcriptions" }], + }, + { + title: "Members", + scopes: [ + { id: "workspace:members:read", label: "Read members" }, + { id: "workspace:members:write", label: "Manage members" }, + ], + }, + { + title: "Billing", + scopes: [{ id: "workspace:billing:read", label: "Read billing" }], + }, + { + title: "Full access", + scopes: [{ id: "workspace:*", label: "Workspace admin" }], + }, +]; + +export default function WorkspaceDeveloperTab({ workspace }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const [keys, setKeys] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + const [newKey, setNewKey] = useState(null); + const [copied, setCopied] = useState(false); + const [name, setName] = useState(""); + const [selectedScopes, setSelectedScopes] = useState>(new Set()); + const [submitting, setSubmitting] = useState(false); + const canManage = workspace.role === "owner" || workspace.role === "admin"; + + async function refresh() { + try { + setKeys(await WorkspaceApiKeysService.list(workspace.id)); + } catch { + setKeys([]); + } + } + + useEffect(() => { + if (canManage) void refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspace.id]); + + function toggleScope(id: string) { + setSelectedScopes((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim() || selectedScopes.size === 0) return; + setSubmitting(true); + try { + const created = await WorkspaceApiKeysService.create(workspace.id, { + name: name.trim(), + scopes: Array.from(selectedScopes), + }); + setNewKey(created); + setName(""); + setSelectedScopes(new Set()); + setCreateOpen(false); + await refresh(); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + } + + async function handleRevoke(keyId: string) { + try { + await WorkspaceApiKeysService.revoke(workspace.id, keyId); + await refresh(); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } + } + + async function handleCopy() { + if (!newKey) return; + await navigator.clipboard.writeText(newKey.key); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } + + return ( +
+
+
+

+ {t("settingsPage.workspace.developer.title")} +

+

+ {t("settingsPage.workspace.developer.description")} +

+
+ {canManage && ( + + )} +
+ +
+ {keys.length === 0 && ( +
+ +

+ {t("settingsPage.workspace.developer.empty")} +

+
+ )} + {keys.map((k) => ( +
+
+

{k.name}

+

+ {k.key_prefix}… +

+
+ + {k.last_used_at + ? t("settingsPage.workspace.developer.lastUsed", { + date: new Date(k.last_used_at).toLocaleDateString(), + }) + : t("settingsPage.workspace.developer.neverUsed")} + + {canManage && ( + + )} +
+ ))} +
+ + + + + {t("settingsPage.workspace.developer.createTitle")} + + {t("settingsPage.workspace.developer.createDescription")} + + +
+
+ + setName(e.target.value)} + autoFocus + placeholder={t("settingsPage.workspace.developer.namePlaceholder")} + required + /> +
+
+ {SCOPE_GROUPS.map((group) => ( +
+

+ {group.title} +

+
+ {group.scopes.map((s) => { + const checked = selectedScopes.has(s.id); + return ( + + ); + })} +
+
+ ))} +
+ + + + +
+
+
+ + !open && setNewKey(null)}> + + + {t("settingsPage.workspace.developer.keyCreatedTitle")} + + {t("settingsPage.workspace.developer.keyCreatedDescription")} + + +
+ {newKey?.key} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/settings/WorkspaceMembersTab.tsx b/src/components/settings/WorkspaceMembersTab.tsx new file mode 100644 index 000000000..2a3e5bc91 --- /dev/null +++ b/src/components/settings/WorkspaceMembersTab.tsx @@ -0,0 +1,244 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Trash2, MoreVertical, Mail, X } from "lucide-react"; +import { useWorkspaceStore } from "../../stores/workspaceStore"; +import { WorkspacesService } from "../../services/WorkspacesService"; +import { InvitationsService } from "../../services/InvitationsService"; +import { Button } from "../ui/button"; +import { useToast } from "../ui/useToast"; +import type { Workspace, WorkspaceInvitation } from "../../types/electron"; +import InviteTeammateDialog from "../InviteTeammateDialog"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "../ui/dropdown-menu"; +import { cn } from "../lib/utils"; + +interface Props { + workspace: Workspace; +} + +export default function WorkspaceMembersTab({ workspace }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const members = useWorkspaceStore((s) => s.members); + const refreshMembers = useWorkspaceStore((s) => s.refreshMembers); + const [invitations, setInvitations] = useState([]); + const [inviteOpen, setInviteOpen] = useState(false); + const canManage = workspace.role === "owner" || workspace.role === "admin"; + + async function refreshInvitations() { + try { + const list = await InvitationsService.list(workspace.id); + setInvitations(list); + } catch { + setInvitations([]); + } + } + + useEffect(() => { + void refreshMembers(workspace.id); + if (canManage) void refreshInvitations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspace.id]); + + async function handleRoleChange(userId: string, role: "owner" | "admin" | "member") { + try { + await WorkspacesService.updateMemberRole(workspace.id, userId, role); + await refreshMembers(workspace.id); + toast({ + title: t("settingsPage.workspace.members.roleUpdated"), + }); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } + } + + async function handleRemove(userId: string) { + try { + await WorkspacesService.removeMember(workspace.id, userId); + await refreshMembers(workspace.id); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } + } + + async function handleRevoke(inviteId: string) { + try { + await InvitationsService.revoke(workspace.id, inviteId); + await refreshInvitations(); + } catch { + // toast handled inline + } + } + + async function handleResend(inviteId: string) { + try { + await InvitationsService.resend(workspace.id, inviteId); + toast({ title: t("settingsPage.workspace.invites.resent") }); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } + } + + return ( +
+
+
+

+ {t("settingsPage.workspace.members.title")} +

+

+ {t("settingsPage.workspace.members.description", { + count: members.length, + seats: workspace.seats, + })} +

+
+ {canManage && ( + + )} +
+ +
+ {members.map((member) => ( +
+ {member.image ? ( + + ) : ( + + {(member.name || member.email).slice(0, 2).toUpperCase()} + + )} +
+

+ {member.name || member.email} +

+ {member.name && ( +

{member.email}

+ )} +
+ + {t(`settingsPage.workspace.role.${member.role}`)} + + {canManage && member.role !== "owner" && ( + + + + + + {member.role !== "admin" && ( + handleRoleChange(member.user_id, "admin")}> + {t("settingsPage.workspace.members.promote")} + + )} + {member.role !== "member" && ( + handleRoleChange(member.user_id, "member")}> + {t("settingsPage.workspace.members.demote")} + + )} + {workspace.role === "owner" && ( + handleRoleChange(member.user_id, "owner")}> + {t("settingsPage.workspace.members.transferOwnership")} + + )} + handleRemove(member.user_id)} + > + + {t("settingsPage.workspace.members.remove")} + + + + )} +
+ ))} + {members.length === 0 && ( +
+ {t("settingsPage.workspace.members.empty")} +
+ )} +
+ + {canManage && invitations.length > 0 && ( +
+

+ {t("settingsPage.workspace.invites.title")} +

+
+ {invitations.map((inv) => ( +
+ +
+

{inv.email}

+
+ + {t(`settingsPage.workspace.role.${inv.workspace_role}`)} + + + +
+ ))} +
+
+ )} + + +
+ ); +} diff --git a/src/components/settings/WorkspaceSection.tsx b/src/components/settings/WorkspaceSection.tsx new file mode 100644 index 000000000..f9bd813e9 --- /dev/null +++ b/src/components/settings/WorkspaceSection.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Users, UserPlus, Trash2 } from "lucide-react"; +import { useWorkspaceStore } from "../../stores/workspaceStore"; +import { WorkspacesService } from "../../services/WorkspacesService"; +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { useToast } from "../ui/useToast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "../ui/dialog"; +import CreateWorkspaceDialog from "../CreateWorkspaceDialog"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, +} from "../ui/dropdown-menu"; +import { cn } from "../lib/utils"; +import WorkspaceMembersTab from "./WorkspaceMembersTab"; +import WorkspaceTeamsTab from "./WorkspaceTeamsTab"; +import WorkspaceBillingTab from "./WorkspaceBillingTab"; +import WorkspaceDeveloperTab from "./WorkspaceDeveloperTab"; +import type { Workspace } from "../../types/electron"; + +const SUB_TABS = ["general", "members", "teams", "billing", "developer"] as const; +type WorkspaceTab = (typeof SUB_TABS)[number]; + +interface Props { + initialSubTab?: string; +} + +export default function WorkspaceSection({ initialSubTab }: Props) { + const { t } = useTranslation(); + const { workspaces, activeWorkspaceId, setActiveWorkspaceId, loaded, refresh } = + useWorkspaceStore(); + const [tab, setTab] = useLocalStorage( + "settings.workspaceTab", + (SUB_TABS.includes(initialSubTab as WorkspaceTab) + ? (initialSubTab as WorkspaceTab) + : "members") + ); + const [createOpen, setCreateOpen] = useState(false); + + useEffect(() => { + if (!loaded) void refresh(); + }, [loaded, refresh]); + + const workspace = activeWorkspaceId + ? workspaces.find((w) => w.id === activeWorkspaceId) + : (workspaces[0] ?? null); + + useEffect(() => { + if (!activeWorkspaceId && workspaces[0]) { + setActiveWorkspaceId(workspaces[0].id); + } + }, [activeWorkspaceId, workspaces, setActiveWorkspaceId]); + + if (!loaded) { + return ( +
+
+
+
+ ); + } + + if (workspaces.length === 0) { + return ( +
+
+

+ {t("settingsPage.workspace.title")} +

+

+ {t("settingsPage.workspace.description")} +

+
+
+ +

+ {t("settingsPage.workspace.empty.title")} +

+

+ {t("settingsPage.workspace.empty.description")} +

+ +
+ +
+ ); + } + + if (!workspace) return null; + + return ( +
+
+
+ {workspaces.length > 1 ? ( + + +

+ {workspace.name} +

+ +
+ + + {t("workspaces.switcher.workspaces")} + + {workspaces.map((w) => ( + setActiveWorkspaceId(w.id)} + className="text-xs" + > + {w.name} + + ))} + + setCreateOpen(true)} className="text-xs"> + + {t("workspaces.switcher.create")} + + +
+ ) : ( +

{workspace.name}

+ )} +

+ {t(`settingsPage.workspace.role.${workspace.role}`)} · {workspace.slug} +

+
+
+ +
+
+ {SUB_TABS.map((id) => ( + + ))} +
+
+ +
+ {tab === "general" && } + {tab === "members" && } + {tab === "teams" && } + {tab === "billing" && } + {tab === "developer" && } +
+ + +
+ ); +} + +function GeneralTab({ workspace }: { workspace: Workspace }) { + const { t } = useTranslation(); + const { toast } = useToast(); + const refresh = useWorkspaceStore((s) => s.refresh); + const setActive = useWorkspaceStore((s) => s.setActiveWorkspaceId); + const [name, setName] = useState(workspace.name); + const [slug, setSlug] = useState(workspace.slug); + const [saving, setSaving] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const isOwner = workspace.role === "owner"; + const dirty = name !== workspace.name || slug !== workspace.slug; + + useEffect(() => { + setName(workspace.name); + setSlug(workspace.slug); + }, [workspace.id, workspace.name, workspace.slug]); + + async function handleSave() { + setSaving(true); + try { + await WorkspacesService.update(workspace.id, { name, slug }); + await refresh(); + toast({ title: t("settingsPage.workspace.general.saved") }); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + setSaving(true); + try { + await WorkspacesService.remove(workspace.id); + setActive(null); + await refresh(); + setConfirmOpen(false); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+ + setName(e.target.value)} + disabled={!isOwner && workspace.role !== "admin"} + maxLength={80} + /> +
+
+ + setSlug(e.target.value)} + disabled={!isOwner && workspace.role !== "admin"} + maxLength={48} + pattern="[a-z0-9-]+" + /> +

+ {t("settingsPage.workspace.general.slugHint")} +

+
+
+ +
+
+ + {isOwner && ( +
+
+

+ {t("settingsPage.workspace.general.dangerTitle")} +

+

+ {t("settingsPage.workspace.general.dangerDescription")} +

+
+ +
+ )} + + + + + {t("settingsPage.workspace.general.confirmTitle")} + + {t("settingsPage.workspace.general.confirmDescription", { name: workspace.name })} + + + + + + + + +
+ ); +} diff --git a/src/components/settings/WorkspaceTeamsTab.tsx b/src/components/settings/WorkspaceTeamsTab.tsx new file mode 100644 index 000000000..0fc3a3e9e --- /dev/null +++ b/src/components/settings/WorkspaceTeamsTab.tsx @@ -0,0 +1,182 @@ +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Users, Trash2 } from "lucide-react"; +import { useWorkspaceStore } from "../../stores/workspaceStore"; +import { TeamsService } from "../../services/TeamsService"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { useToast } from "../ui/useToast"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "../ui/dialog"; +import type { Workspace, Team } from "../../types/electron"; + +interface Props { + workspace: Workspace; +} + +export default function WorkspaceTeamsTab({ workspace }: Props) { + const { t } = useTranslation(); + const { toast } = useToast(); + const teams = useWorkspaceStore((s) => s.teams); + const refreshTeams = useWorkspaceStore((s) => s.refreshTeams); + const [createOpen, setCreateOpen] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [submitting, setSubmitting] = useState(false); + const canManage = workspace.role === "owner" || workspace.role === "admin"; + + useEffect(() => { + void refreshTeams(workspace.id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspace.id]); + + async function handleCreate(e: React.FormEvent) { + e.preventDefault(); + if (!name.trim()) return; + setSubmitting(true); + try { + await TeamsService.create(workspace.id, { + name: name.trim(), + description: description.trim() || undefined, + }); + await refreshTeams(workspace.id); + setName(""); + setDescription(""); + setCreateOpen(false); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } finally { + setSubmitting(false); + } + } + + async function handleDelete(team: Team) { + try { + await TeamsService.remove(team.id); + await refreshTeams(workspace.id); + } catch (error) { + toast({ + title: t("common.error"), + description: error instanceof Error ? error.message : t("common.unknownError"), + variant: "destructive", + }); + } + } + + return ( +
+
+
+

+ {t("settingsPage.workspace.teams.title")} +

+

+ {t("settingsPage.workspace.teams.description")} +

+
+ {canManage && ( + + )} +
+ +
+ {teams.length === 0 && ( +
+ +

+ {t("settingsPage.workspace.teams.empty")} +

+ {canManage && ( + + )} +
+ )} + {teams.map((team) => ( +
+
+

{team.name}

+ {team.description && ( +

{team.description}

+ )} +
+ + {t("settingsPage.workspace.teams.memberCount", { count: team.member_count ?? 0 })} + + {canManage && ( + + )} +
+ ))} +
+ + + + + {t("settingsPage.workspace.teams.createTitle")} + +
+
+ + setName(e.target.value)} + autoFocus + maxLength={80} + required + /> +
+
+ + setDescription(e.target.value)} + maxLength={280} + /> +
+ + + + +
+
+
+
+ ); +} diff --git a/src/hooks/useWorkspace.ts b/src/hooks/useWorkspace.ts new file mode 100644 index 000000000..7c2eb1724 --- /dev/null +++ b/src/hooks/useWorkspace.ts @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useWorkspaceStore } from "../stores/workspaceStore"; +import type { Workspace, WorkspaceRole } from "../types/electron"; + +interface UseWorkspaceResult { + workspaces: Workspace[]; + active: Workspace | null; + role: WorkspaceRole | null; + loaded: boolean; + setActive: (id: string | null) => void; + refresh: () => Promise; +} + +export function useWorkspace(): UseWorkspaceResult { + const workspaces = useWorkspaceStore((s) => s.workspaces); + const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId); + const loaded = useWorkspaceStore((s) => s.loaded); + const refresh = useWorkspaceStore((s) => s.refresh); + const setActive = useWorkspaceStore((s) => s.setActiveWorkspaceId); + + useEffect(() => { + if (!loaded) { + void refresh(); + } + }, [loaded, refresh]); + + const active = activeWorkspaceId + ? (workspaces.find((w) => w.id === activeWorkspaceId) ?? null) + : null; + + return { + workspaces, + active, + role: active?.role ?? null, + loaded, + setActive, + refresh, + }; +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index e81abc505..ad5731540 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -155,7 +155,17 @@ "private": "Privat", "tap": "Tippen", "close": "Schließen", - "dismiss": "Verwerfen" + "dismiss": "Verwerfen", + "save": "Speichern", + "saving": "Speichern…", + "create": "Erstellen", + "delete": "Löschen", + "actions": "Aktionen", + "error": "Etwas ist schiefgelaufen", + "unknownError": "Ein unbekannter Fehler ist aufgetreten", + "copy": "Kopieren", + "copied": "Kopiert", + "done": "Fertig" }, "onboarding": { "steps": { @@ -937,6 +947,10 @@ "system": { "description": "Updates, Speicher & Entwicklertools", "label": "System" + }, + "workspace": { + "label": "Workspace", + "description": "Mitglieder, Teams, Abrechnung und API-Schlüssel" } }, "title": "Einstellungen" @@ -1604,6 +1618,91 @@ "endpointUrl": "Endpunkt-URL", "serverUrl": "Server-URL", "apiKeyOptional": "API-Schlüssel (optional)" + }, + "workspace": { + "title": "Workspace", + "description": "Arbeiten Sie zusammen, teilen Sie Notizen und verwalten Sie die Abrechnung an einem Ort.", + "empty": { + "title": "Ersten Workspace erstellen", + "description": "Workspaces ermöglichen die Zusammenarbeit und das Teilen von Notizen.", + "create": "Workspace erstellen" + }, + "tab": { + "general": "Allgemein", + "members": "Mitglieder", + "teams": "Teams", + "billing": "Abrechnung", + "developer": "Entwickler" + }, + "role": { + "owner": "Inhaber", + "admin": "Administrator", + "member": "Mitglied" + }, + "general": { + "nameLabel": "Workspace-Name", + "slugLabel": "Slug", + "slugHint": "Wird in URLs verwendet. Nur Kleinbuchstaben, Ziffern und Bindestriche.", + "saved": "Workspace aktualisiert", + "dangerTitle": "Workspace löschen", + "dangerDescription": "Alle geteilten Notizen, Ordner und Integrationen werden gelöscht. Dies kann nicht rückgängig gemacht werden.", + "delete": "Workspace löschen", + "confirmTitle": "{{name}} löschen?", + "confirmDescription": "Dies kündigt das Abonnement und entfernt alle Workspace-Daten dauerhaft." + }, + "members": { + "title": "Mitglieder", + "description": "{{count}} von {{seats}} Plätzen belegt.", + "invite": "Einladen", + "empty": "Noch keine Mitglieder.", + "roleUpdated": "Rolle aktualisiert", + "promote": "Zum Administrator machen", + "demote": "Zum Mitglied machen", + "transferOwnership": "Eigentum übertragen", + "remove": "Aus Workspace entfernen" + }, + "invites": { + "title": "Ausstehende Einladungen", + "resend": "Erneut senden", + "revoke": "Widerrufen", + "resent": "Einladung erneut gesendet" + }, + "teams": { + "title": "Teams", + "description": "Mitglieder gruppieren für gezieltes Teilen.", + "new": "Neues Team", + "empty": "Noch keine Teams.", + "createFirst": "Erstellen Sie Ihr erstes Team", + "createTitle": "Team erstellen", + "nameLabel": "Team-Name", + "descriptionLabel": "Beschreibung", + "memberCount": "{{count}} Mitglieder" + }, + "billing": { + "title": "Abrechnung", + "description": "Plan, Plätze und Zahlung.", + "plan": "Plan", + "seats": "Plätze", + "nextInvoice": "Nächste Rechnung", + "manageStripe": "In Stripe verwalten", + "startSubscription": "Abonnement starten" + }, + "developer": { + "title": "API-Schlüssel", + "description": "Programmatischer Zugriff auf diesen Workspace.", + "empty": "Noch keine API-Schlüssel. Erstellen Sie einen für Integrationen.", + "new": "Neuer Schlüssel", + "create": "Schlüssel erstellen", + "createTitle": "API-Schlüssel erstellen", + "createDescription": "Wählen Sie die Berechtigungen. Sie können den Schlüssel jederzeit widerrufen.", + "nameLabel": "Schlüsselname", + "namePlaceholder": "Produktionsintegration", + "revoke": "Schlüssel widerrufen", + "lastUsed": "Zuletzt verwendet {{date}}", + "neverUsed": "Nie verwendet", + "keyCreatedTitle": "API-Schlüssel erstellt", + "keyCreatedDescription": "Kopieren Sie diesen Schlüssel jetzt — Sie können ihn nicht erneut sehen." + } } }, "models": { @@ -2043,7 +2142,9 @@ "integrations": "Integrationen", "defaultUser": "Benutzer", "notSignedIn": "Nicht angemeldet", - "chat": "Chat" + "chat": "Chat", + "inviteTeammate": "Teammitglied einladen", + "createWorkspace": "Workspace erstellen" }, "chat": { "newChat": "Neuer Chat", @@ -2499,5 +2600,53 @@ "you": "Du", "others": "Andere" } + }, + "workspaces": { + "switcher": { + "personal": "Persönlich", + "workspaces": "Workspaces", + "empty": "Noch keine Workspaces", + "create": "Workspace erstellen…" + }, + "create": { + "title": "Workspace erstellen", + "description": "Workspaces ermöglichen Zusammenarbeit, geteilte Notizen und gemeinsame Abrechnung.", + "nameLabel": "Workspace-Name", + "namePlaceholder": "Acme Corp", + "submit": "Workspace erstellen", + "submitting": "Erstelle…", + "errorTitle": "Workspace konnte nicht erstellt werden" + }, + "created": { + "title": "Workspace erstellt", + "description": "Sie sind nun Inhaber von {{name}}." + }, + "invite": { + "title": "Zu {{workspace}} einladen", + "description": "Fügen Sie Teammitglieder per E-Mail hinzu. Sie erhalten einen Beitrittslink.", + "emailLabel": "E-Mail", + "roleLabel": "Rolle", + "teamsLabel": "Zu Teams hinzufügen", + "submit": "Einladung senden", + "submitting": "Sende…", + "sentTitle": "Einladung gesendet", + "sentDescription": "Einladung an {{email}} gesendet.", + "errorTitle": "Einladung konnte nicht gesendet werden", + "role": { + "admin": "Administrator", + "member": "Mitglied" + } + }, + "accept": { + "title": "Workspace beitreten", + "description": "{{inviter}} hat Sie eingeladen, {{workspace}} als {{role}} beizutreten.", + "loading": "Lade Einladung…", + "accept": "Einladung annehmen", + "accepting": "Annehmen…", + "signInToAccept": "Anmelden zum Annehmen", + "successTitle": "Beigetreten", + "successDescription": "Willkommen bei {{name}}.", + "errorTitle": "Einladung konnte nicht angenommen werden" + } } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index a58aacc76..0ba5ec742 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -155,7 +155,17 @@ "private": "Private", "tap": "Tap", "close": "Close", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "save": "Save", + "saving": "Saving…", + "create": "Create", + "delete": "Delete", + "actions": "Actions", + "error": "Something went wrong", + "unknownError": "An unknown error occurred", + "copy": "Copy", + "copied": "Copied", + "done": "Done" }, "onboarding": { "steps": { @@ -1010,6 +1020,10 @@ "description": "Plans, usage & billing", "label": "Plans & Billing" }, + "workspace": { + "description": "Members, teams, billing & API keys", + "label": "Workspace" + }, "general": { "description": "Appearance, sounds & startup", "label": "Preferences" @@ -1038,6 +1052,91 @@ "title": "Settings" }, "settingsPage": { + "workspace": { + "title": "Workspace", + "description": "Collaborate with teammates, share notes, and manage billing in one place.", + "empty": { + "title": "Create your first workspace", + "description": "Workspaces let you collaborate with teammates and share notes.", + "create": "Create workspace" + }, + "tab": { + "general": "General", + "members": "Members", + "teams": "Teams", + "billing": "Billing", + "developer": "Developer" + }, + "role": { + "owner": "Owner", + "admin": "Admin", + "member": "Member" + }, + "general": { + "nameLabel": "Workspace name", + "slugLabel": "Slug", + "slugHint": "Used in URLs and invite links. Lowercase letters, digits, and dashes only.", + "saved": "Workspace updated", + "dangerTitle": "Delete workspace", + "dangerDescription": "All shared notes, folders, and integrations will be deleted. This cannot be undone.", + "delete": "Delete workspace", + "confirmTitle": "Delete {{name}}?", + "confirmDescription": "This cancels the subscription and removes all workspace data permanently." + }, + "members": { + "title": "Members", + "description": "{{count}} of {{seats}} seats used.", + "invite": "Invite", + "empty": "No members yet.", + "roleUpdated": "Role updated", + "promote": "Make admin", + "demote": "Make member", + "transferOwnership": "Transfer ownership", + "remove": "Remove from workspace" + }, + "invites": { + "title": "Pending invitations", + "resend": "Resend", + "revoke": "Revoke", + "resent": "Invitation resent" + }, + "teams": { + "title": "Teams", + "description": "Group members for scoped sharing.", + "new": "New team", + "empty": "No teams yet.", + "createFirst": "Create your first team", + "createTitle": "Create team", + "nameLabel": "Team name", + "descriptionLabel": "Description", + "memberCount": "{{count}} members" + }, + "billing": { + "title": "Billing", + "description": "Plan, seats, and payment.", + "plan": "Plan", + "seats": "Seats", + "nextInvoice": "Next invoice", + "manageStripe": "Manage in Stripe", + "startSubscription": "Start subscription" + }, + "developer": { + "title": "API keys", + "description": "Programmatic access scoped to this workspace.", + "empty": "No API keys yet. Create one to integrate with this workspace.", + "new": "New key", + "create": "Create key", + "createTitle": "Create API key", + "createDescription": "Choose the permissions this key needs. You can revoke it any time.", + "nameLabel": "Key name", + "namePlaceholder": "Production integration", + "revoke": "Revoke key", + "lastUsed": "Last used {{date}}", + "neverUsed": "Never used", + "keyCreatedTitle": "API key created", + "keyCreatedDescription": "Copy this key now — you won't be able to see it again." + } + }, "account": { "badges": { "free": "Free", @@ -1810,7 +1909,57 @@ "integrations": "Integrations", "defaultUser": "User", "notSignedIn": "Not signed in", - "chat": "Chat" + "chat": "Chat", + "inviteTeammate": "Invite teammate", + "createWorkspace": "Create workspace" + }, + "workspaces": { + "switcher": { + "personal": "Personal", + "workspaces": "Workspaces", + "empty": "No workspaces yet", + "create": "Create workspace…" + }, + "create": { + "title": "Create workspace", + "description": "Workspaces let you collaborate, share notes, and manage billing in one place.", + "nameLabel": "Workspace name", + "namePlaceholder": "Acme Corp", + "submit": "Create workspace", + "submitting": "Creating…", + "errorTitle": "Couldn't create workspace" + }, + "created": { + "title": "Workspace created", + "description": "You're now the owner of {{name}}." + }, + "invite": { + "title": "Invite to {{workspace}}", + "description": "Add teammates by email. They'll get a link to join.", + "emailLabel": "Email", + "roleLabel": "Role", + "teamsLabel": "Add to teams", + "submit": "Send invite", + "submitting": "Sending…", + "sentTitle": "Invite sent", + "sentDescription": "We sent an invitation to {{email}}.", + "errorTitle": "Couldn't send invitation", + "role": { + "admin": "Admin", + "member": "Member" + } + }, + "accept": { + "title": "Join workspace", + "description": "{{inviter}} invited you to join {{workspace}} as a {{role}}.", + "loading": "Loading invitation…", + "accept": "Accept invitation", + "accepting": "Accepting…", + "signInToAccept": "Sign in to accept", + "successTitle": "You're in", + "successDescription": "Welcome to {{name}}.", + "errorTitle": "Couldn't accept invitation" + } }, "chat": { "newChat": "New chat", diff --git a/src/locales/es/translation.json b/src/locales/es/translation.json index 4c529163d..717af76e0 100644 --- a/src/locales/es/translation.json +++ b/src/locales/es/translation.json @@ -155,7 +155,17 @@ "private": "Privado", "tap": "Pulsar", "close": "Cerrar", - "dismiss": "Descartar" + "dismiss": "Descartar", + "save": "Guardar", + "saving": "Guardando…", + "create": "Crear", + "delete": "Eliminar", + "actions": "Acciones", + "error": "Algo salió mal", + "unknownError": "Ocurrió un error desconocido", + "copy": "Copiar", + "copied": "Copiado", + "done": "Listo" }, "onboarding": { "steps": { @@ -985,6 +995,10 @@ "system": { "description": "Actualizaciones, almacenamiento y herramientas de desarrollo", "label": "Sistema" + }, + "workspace": { + "label": "Espacio de trabajo", + "description": "Miembros, equipos, facturación y claves API" } }, "title": "Ajustes" @@ -1652,6 +1666,91 @@ "endpointUrl": "URL del endpoint", "serverUrl": "URL del servidor", "apiKeyOptional": "API Key (opcional)" + }, + "workspace": { + "title": "Espacio de trabajo", + "description": "Colabora con compañeros, comparte notas y administra la facturación en un solo lugar.", + "empty": { + "title": "Crea tu primer espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar con compañeros y compartir notas.", + "create": "Crear espacio de trabajo" + }, + "tab": { + "general": "General", + "members": "Miembros", + "teams": "Equipos", + "billing": "Facturación", + "developer": "Desarrollador" + }, + "role": { + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" + }, + "general": { + "nameLabel": "Nombre del espacio de trabajo", + "slugLabel": "Slug", + "slugHint": "Se usa en URLs y enlaces de invitación. Solo letras minúsculas, dígitos y guiones.", + "saved": "Espacio de trabajo actualizado", + "dangerTitle": "Eliminar espacio de trabajo", + "dangerDescription": "Todas las notas, carpetas e integraciones compartidas se eliminarán. Esto no se puede deshacer.", + "delete": "Eliminar espacio de trabajo", + "confirmTitle": "¿Eliminar {{name}}?", + "confirmDescription": "Esto cancela la suscripción y elimina todos los datos del espacio de trabajo permanentemente." + }, + "members": { + "title": "Miembros", + "description": "{{count}} de {{seats}} asientos utilizados.", + "invite": "Invitar", + "empty": "Aún no hay miembros.", + "roleUpdated": "Rol actualizado", + "promote": "Hacer administrador", + "demote": "Hacer miembro", + "transferOwnership": "Transferir propiedad", + "remove": "Quitar del espacio de trabajo" + }, + "invites": { + "title": "Invitaciones pendientes", + "resend": "Reenviar", + "revoke": "Revocar", + "resent": "Invitación reenviada" + }, + "teams": { + "title": "Equipos", + "description": "Agrupa miembros para compartir con alcance limitado.", + "new": "Nuevo equipo", + "empty": "Aún no hay equipos.", + "createFirst": "Crea tu primer equipo", + "createTitle": "Crear equipo", + "nameLabel": "Nombre del equipo", + "descriptionLabel": "Descripción", + "memberCount": "{{count}} miembros" + }, + "billing": { + "title": "Facturación", + "description": "Plan, asientos y pago.", + "plan": "Plan", + "seats": "Asientos", + "nextInvoice": "Próxima factura", + "manageStripe": "Administrar en Stripe", + "startSubscription": "Iniciar suscripción" + }, + "developer": { + "title": "Claves API", + "description": "Acceso programático limitado a este espacio de trabajo.", + "empty": "Aún no hay claves API. Crea una para integrarte con este espacio de trabajo.", + "new": "Nueva clave", + "create": "Crear clave", + "createTitle": "Crear clave API", + "createDescription": "Elige los permisos que necesita esta clave. Puedes revocarla en cualquier momento.", + "nameLabel": "Nombre de la clave", + "namePlaceholder": "Integración de producción", + "revoke": "Revocar clave", + "lastUsed": "Último uso {{date}}", + "neverUsed": "Nunca utilizada", + "keyCreatedTitle": "Clave API creada", + "keyCreatedDescription": "Copia esta clave ahora — no podrás verla de nuevo." + } } }, "models": { @@ -2091,7 +2190,9 @@ "integrations": "Integraciones", "defaultUser": "Usuario", "notSignedIn": "Sin sesión iniciada", - "chat": "Chat" + "chat": "Chat", + "inviteTeammate": "Invitar compañero", + "createWorkspace": "Crear espacio de trabajo" }, "chat": { "newChat": "Nuevo chat", @@ -2499,5 +2600,53 @@ "you": "Tú", "others": "Otros" } + }, + "workspaces": { + "switcher": { + "personal": "Personal", + "workspaces": "Espacios de trabajo", + "empty": "Aún no hay espacios de trabajo", + "create": "Crear espacio de trabajo…" + }, + "create": { + "title": "Crear espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar, compartir notas y administrar la facturación.", + "nameLabel": "Nombre del espacio de trabajo", + "namePlaceholder": "Acme Corp", + "submit": "Crear espacio de trabajo", + "submitting": "Creando…", + "errorTitle": "No se pudo crear el espacio de trabajo" + }, + "created": { + "title": "Espacio de trabajo creado", + "description": "Ahora eres el propietario de {{name}}." + }, + "invite": { + "title": "Invitar a {{workspace}}", + "description": "Agrega compañeros por correo. Recibirán un enlace para unirse.", + "emailLabel": "Correo electrónico", + "roleLabel": "Rol", + "teamsLabel": "Agregar a equipos", + "submit": "Enviar invitación", + "submitting": "Enviando…", + "sentTitle": "Invitación enviada", + "sentDescription": "Enviamos una invitación a {{email}}.", + "errorTitle": "No se pudo enviar la invitación", + "role": { + "admin": "Administrador", + "member": "Miembro" + } + }, + "accept": { + "title": "Unirse al espacio de trabajo", + "description": "{{inviter}} te invitó a unirte a {{workspace}} como {{role}}.", + "loading": "Cargando invitación…", + "accept": "Aceptar invitación", + "accepting": "Aceptando…", + "signInToAccept": "Inicia sesión para aceptar", + "successTitle": "Te uniste", + "successDescription": "Bienvenido a {{name}}.", + "errorTitle": "No se pudo aceptar la invitación" + } } } diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index d14c4eb8a..9c8480ff4 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -155,7 +155,17 @@ "private": "Privé", "tap": "Appui", "close": "Fermer", - "dismiss": "Ignorer" + "dismiss": "Ignorer", + "save": "Enregistrer", + "saving": "Enregistrement…", + "create": "Créer", + "delete": "Supprimer", + "actions": "Actions", + "error": "Une erreur s'est produite", + "unknownError": "Une erreur inconnue s'est produite", + "copy": "Copier", + "copied": "Copié", + "done": "Terminé" }, "onboarding": { "steps": { @@ -985,6 +995,10 @@ "system": { "description": "Mises à jour, stockage & outils de développement", "label": "Système" + }, + "workspace": { + "label": "Espace de travail", + "description": "Membres, équipes, facturation et clés API" } }, "title": "Paramètres" @@ -1652,6 +1666,91 @@ "endpointUrl": "URL du point d'accès", "serverUrl": "URL du serveur", "apiKeyOptional": "Clé API (facultative)" + }, + "workspace": { + "title": "Espace de travail", + "description": "Collaborez, partagez des notes et gérez la facturation au même endroit.", + "empty": { + "title": "Créez votre premier espace de travail", + "description": "Les espaces de travail vous permettent de collaborer et partager des notes.", + "create": "Créer un espace" + }, + "tab": { + "general": "Général", + "members": "Membres", + "teams": "Équipes", + "billing": "Facturation", + "developer": "Développeur" + }, + "role": { + "owner": "Propriétaire", + "admin": "Administrateur", + "member": "Membre" + }, + "general": { + "nameLabel": "Nom", + "slugLabel": "Slug", + "slugHint": "Utilisé dans les URL. Minuscules, chiffres et tirets uniquement.", + "saved": "Espace mis à jour", + "dangerTitle": "Supprimer l'espace", + "dangerDescription": "Toutes les notes, dossiers et intégrations partagés seront supprimés. Cette action est irréversible.", + "delete": "Supprimer l'espace", + "confirmTitle": "Supprimer {{name}} ?", + "confirmDescription": "Cela annule l'abonnement et supprime définitivement toutes les données." + }, + "members": { + "title": "Membres", + "description": "{{count}} sur {{seats}} sièges utilisés.", + "invite": "Inviter", + "empty": "Aucun membre pour l'instant.", + "roleUpdated": "Rôle mis à jour", + "promote": "Promouvoir administrateur", + "demote": "Rétrograder membre", + "transferOwnership": "Transférer la propriété", + "remove": "Retirer de l'espace" + }, + "invites": { + "title": "Invitations en attente", + "resend": "Renvoyer", + "revoke": "Révoquer", + "resent": "Invitation renvoyée" + }, + "teams": { + "title": "Équipes", + "description": "Groupez les membres pour un partage ciblé.", + "new": "Nouvelle équipe", + "empty": "Aucune équipe.", + "createFirst": "Créez votre première équipe", + "createTitle": "Créer une équipe", + "nameLabel": "Nom de l'équipe", + "descriptionLabel": "Description", + "memberCount": "{{count}} membres" + }, + "billing": { + "title": "Facturation", + "description": "Plan, sièges et paiement.", + "plan": "Plan", + "seats": "Sièges", + "nextInvoice": "Prochaine facture", + "manageStripe": "Gérer dans Stripe", + "startSubscription": "Démarrer l'abonnement" + }, + "developer": { + "title": "Clés API", + "description": "Accès programmatique limité à cet espace.", + "empty": "Aucune clé API. Créez-en une pour vous intégrer.", + "new": "Nouvelle clé", + "create": "Créer la clé", + "createTitle": "Créer une clé API", + "createDescription": "Choisissez les permissions. Vous pouvez la révoquer à tout moment.", + "nameLabel": "Nom de la clé", + "namePlaceholder": "Intégration production", + "revoke": "Révoquer la clé", + "lastUsed": "Dernière utilisation {{date}}", + "neverUsed": "Jamais utilisée", + "keyCreatedTitle": "Clé API créée", + "keyCreatedDescription": "Copiez cette clé maintenant — vous ne pourrez plus la voir." + } } }, "models": { @@ -1762,7 +1861,9 @@ "integrations": "Intégrations", "defaultUser": "Utilisateur", "notSignedIn": "Non connecté", - "chat": "Chat" + "chat": "Chat", + "inviteTeammate": "Inviter un coéquipier", + "createWorkspace": "Créer un espace de travail" }, "chat": { "newChat": "Nouveau chat", @@ -2499,5 +2600,53 @@ "you": "Vous", "others": "Autres" } + }, + "workspaces": { + "switcher": { + "personal": "Personnel", + "workspaces": "Espaces de travail", + "empty": "Aucun espace de travail", + "create": "Créer un espace de travail…" + }, + "create": { + "title": "Créer un espace de travail", + "description": "Les espaces de travail vous permettent de collaborer, partager des notes et gérer la facturation.", + "nameLabel": "Nom de l'espace de travail", + "namePlaceholder": "Acme Corp", + "submit": "Créer l'espace", + "submitting": "Création…", + "errorTitle": "Impossible de créer l'espace" + }, + "created": { + "title": "Espace de travail créé", + "description": "Vous êtes maintenant propriétaire de {{name}}." + }, + "invite": { + "title": "Inviter dans {{workspace}}", + "description": "Ajoutez des coéquipiers par e-mail. Ils recevront un lien pour rejoindre.", + "emailLabel": "E-mail", + "roleLabel": "Rôle", + "teamsLabel": "Ajouter aux équipes", + "submit": "Envoyer l'invitation", + "submitting": "Envoi…", + "sentTitle": "Invitation envoyée", + "sentDescription": "Nous avons envoyé une invitation à {{email}}.", + "errorTitle": "Impossible d'envoyer l'invitation", + "role": { + "admin": "Administrateur", + "member": "Membre" + } + }, + "accept": { + "title": "Rejoindre l'espace", + "description": "{{inviter}} vous a invité à rejoindre {{workspace}} en tant que {{role}}.", + "loading": "Chargement…", + "accept": "Accepter l'invitation", + "accepting": "Acceptation…", + "signInToAccept": "Connectez-vous pour accepter", + "successTitle": "Vous êtes membre", + "successDescription": "Bienvenue dans {{name}}.", + "errorTitle": "Impossible d'accepter l'invitation" + } } } diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index cbfede877..8c5797266 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -155,7 +155,17 @@ "private": "Privato", "tap": "Tocca", "close": "Chiudi", - "dismiss": "Ignora" + "dismiss": "Ignora", + "save": "Enregistrer", + "saving": "Enregistrement…", + "create": "Créer", + "delete": "Supprimer", + "actions": "Actions", + "error": "Une erreur s'est produite", + "unknownError": "Une erreur inconnue s'est produite", + "copy": "Copier", + "copied": "Copié", + "done": "Terminé" }, "onboarding": { "steps": { @@ -937,6 +947,10 @@ "system": { "description": "Aggiornamenti, archiviazione e strumenti di sviluppo", "label": "Sistema" + }, + "workspace": { + "label": "Espace de travail", + "description": "Membres, équipes, facturation et clés API" } }, "title": "Impostazioni" @@ -1604,6 +1618,91 @@ "endpointUrl": "URL endpoint", "serverUrl": "URL server", "apiKeyOptional": "API Key (opzionale)" + }, + "workspace": { + "title": "Espace de travail", + "description": "Collaborez, partagez des notes et gérez la facturation au même endroit.", + "empty": { + "title": "Créez votre premier espace de travail", + "description": "Les espaces de travail vous permettent de collaborer et partager des notes.", + "create": "Créer un espace" + }, + "tab": { + "general": "Général", + "members": "Membres", + "teams": "Équipes", + "billing": "Facturation", + "developer": "Développeur" + }, + "role": { + "owner": "Propriétaire", + "admin": "Administrateur", + "member": "Membre" + }, + "general": { + "nameLabel": "Nom", + "slugLabel": "Slug", + "slugHint": "Utilisé dans les URL. Minuscules, chiffres et tirets uniquement.", + "saved": "Espace mis à jour", + "dangerTitle": "Supprimer l'espace", + "dangerDescription": "Toutes les notes, dossiers et intégrations partagés seront supprimés. Cette action est irréversible.", + "delete": "Supprimer l'espace", + "confirmTitle": "Supprimer {{name}} ?", + "confirmDescription": "Cela annule l'abonnement et supprime définitivement toutes les données." + }, + "members": { + "title": "Membres", + "description": "{{count}} sur {{seats}} sièges utilisés.", + "invite": "Inviter", + "empty": "Aucun membre pour l'instant.", + "roleUpdated": "Rôle mis à jour", + "promote": "Promouvoir administrateur", + "demote": "Rétrograder membre", + "transferOwnership": "Transférer la propriété", + "remove": "Retirer de l'espace" + }, + "invites": { + "title": "Invitations en attente", + "resend": "Renvoyer", + "revoke": "Révoquer", + "resent": "Invitation renvoyée" + }, + "teams": { + "title": "Équipes", + "description": "Groupez les membres pour un partage ciblé.", + "new": "Nouvelle équipe", + "empty": "Aucune équipe.", + "createFirst": "Créez votre première équipe", + "createTitle": "Créer une équipe", + "nameLabel": "Nom de l'équipe", + "descriptionLabel": "Description", + "memberCount": "{{count}} membres" + }, + "billing": { + "title": "Facturation", + "description": "Plan, sièges et paiement.", + "plan": "Plan", + "seats": "Sièges", + "nextInvoice": "Prochaine facture", + "manageStripe": "Gérer dans Stripe", + "startSubscription": "Démarrer l'abonnement" + }, + "developer": { + "title": "Clés API", + "description": "Accès programmatique limité à cet espace.", + "empty": "Aucune clé API. Créez-en une pour vous intégrer.", + "new": "Nouvelle clé", + "create": "Créer la clé", + "createTitle": "Créer une clé API", + "createDescription": "Choisissez les permissions. Vous pouvez la révoquer à tout moment.", + "nameLabel": "Nom de la clé", + "namePlaceholder": "Intégration production", + "revoke": "Révoquer la clé", + "lastUsed": "Dernière utilisation {{date}}", + "neverUsed": "Jamais utilisée", + "keyCreatedTitle": "Clé API créée", + "keyCreatedDescription": "Copiez cette clé maintenant — vous ne pourrez plus la voir." + } } }, "models": { @@ -2043,7 +2142,9 @@ "integrations": "Integrazioni", "defaultUser": "Utente", "notSignedIn": "Non hai effettuato l'accesso", - "chat": "Chat" + "chat": "Chat", + "inviteTeammate": "Inviter un coéquipier", + "createWorkspace": "Créer un espace de travail" }, "chat": { "newChat": "Nuova chat", @@ -2499,5 +2600,53 @@ "you": "Tu", "others": "Altri" } + }, + "workspaces": { + "switcher": { + "personal": "Personnel", + "workspaces": "Espaces de travail", + "empty": "Aucun espace de travail", + "create": "Créer un espace de travail…" + }, + "create": { + "title": "Créer un espace de travail", + "description": "Les espaces de travail vous permettent de collaborer, partager des notes et gérer la facturation.", + "nameLabel": "Nom de l'espace de travail", + "namePlaceholder": "Acme Corp", + "submit": "Créer l'espace", + "submitting": "Création…", + "errorTitle": "Impossible de créer l'espace" + }, + "created": { + "title": "Espace de travail créé", + "description": "Vous êtes maintenant propriétaire de {{name}}." + }, + "invite": { + "title": "Inviter dans {{workspace}}", + "description": "Ajoutez des coéquipiers par e-mail. Ils recevront un lien pour rejoindre.", + "emailLabel": "E-mail", + "roleLabel": "Rôle", + "teamsLabel": "Ajouter aux équipes", + "submit": "Envoyer l'invitation", + "submitting": "Envoi…", + "sentTitle": "Invitation envoyée", + "sentDescription": "Nous avons envoyé une invitation à {{email}}.", + "errorTitle": "Impossible d'envoyer l'invitation", + "role": { + "admin": "Administrateur", + "member": "Membre" + } + }, + "accept": { + "title": "Rejoindre l'espace", + "description": "{{inviter}} vous a invité à rejoindre {{workspace}} en tant que {{role}}.", + "loading": "Chargement…", + "accept": "Accepter l'invitation", + "accepting": "Acceptation…", + "signInToAccept": "Connectez-vous pour accepter", + "successTitle": "Vous êtes membre", + "successDescription": "Bienvenue dans {{name}}.", + "errorTitle": "Impossible d'accepter l'invitation" + } } } diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index 5b237fd3d..95663ac1b 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -155,7 +155,17 @@ "private": "プライベート", "tap": "タップ", "close": "閉じる", - "dismiss": "閉じる" + "dismiss": "閉じる", + "save": "Guardar", + "saving": "Guardando…", + "create": "Crear", + "delete": "Eliminar", + "actions": "Acciones", + "error": "Algo salió mal", + "unknownError": "Ocurrió un error desconocido", + "copy": "Copiar", + "copied": "Copiado", + "done": "Listo" }, "onboarding": { "steps": { @@ -937,6 +947,10 @@ "system": { "description": "更新、ストレージ、開発者ツール", "label": "システム" + }, + "workspace": { + "label": "Espacio de trabajo", + "description": "Miembros, equipos, facturación y claves API" } }, "title": "設定" @@ -1604,6 +1618,91 @@ "endpointUrl": "エンドポイント URL", "serverUrl": "サーバー URL", "apiKeyOptional": "API キー(任意)" + }, + "workspace": { + "title": "Espacio de trabajo", + "description": "Colabora con compañeros, comparte notas y administra la facturación en un solo lugar.", + "empty": { + "title": "Crea tu primer espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar con compañeros y compartir notas.", + "create": "Crear espacio de trabajo" + }, + "tab": { + "general": "General", + "members": "Miembros", + "teams": "Equipos", + "billing": "Facturación", + "developer": "Desarrollador" + }, + "role": { + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" + }, + "general": { + "nameLabel": "Nombre del espacio de trabajo", + "slugLabel": "Slug", + "slugHint": "Se usa en URLs y enlaces de invitación. Solo letras minúsculas, dígitos y guiones.", + "saved": "Espacio de trabajo actualizado", + "dangerTitle": "Eliminar espacio de trabajo", + "dangerDescription": "Todas las notas, carpetas e integraciones compartidas se eliminarán. Esto no se puede deshacer.", + "delete": "Eliminar espacio de trabajo", + "confirmTitle": "¿Eliminar {{name}}?", + "confirmDescription": "Esto cancela la suscripción y elimina todos los datos del espacio de trabajo permanentemente." + }, + "members": { + "title": "Miembros", + "description": "{{count}} de {{seats}} asientos utilizados.", + "invite": "Invitar", + "empty": "Aún no hay miembros.", + "roleUpdated": "Rol actualizado", + "promote": "Hacer administrador", + "demote": "Hacer miembro", + "transferOwnership": "Transferir propiedad", + "remove": "Quitar del espacio de trabajo" + }, + "invites": { + "title": "Invitaciones pendientes", + "resend": "Reenviar", + "revoke": "Revocar", + "resent": "Invitación reenviada" + }, + "teams": { + "title": "Equipos", + "description": "Agrupa miembros para compartir con alcance limitado.", + "new": "Nuevo equipo", + "empty": "Aún no hay equipos.", + "createFirst": "Crea tu primer equipo", + "createTitle": "Crear equipo", + "nameLabel": "Nombre del equipo", + "descriptionLabel": "Descripción", + "memberCount": "{{count}} miembros" + }, + "billing": { + "title": "Facturación", + "description": "Plan, asientos y pago.", + "plan": "Plan", + "seats": "Asientos", + "nextInvoice": "Próxima factura", + "manageStripe": "Administrar en Stripe", + "startSubscription": "Iniciar suscripción" + }, + "developer": { + "title": "Claves API", + "description": "Acceso programático limitado a este espacio de trabajo.", + "empty": "Aún no hay claves API. Crea una para integrarte con este espacio de trabajo.", + "new": "Nueva clave", + "create": "Crear clave", + "createTitle": "Crear clave API", + "createDescription": "Elige los permisos que necesita esta clave. Puedes revocarla en cualquier momento.", + "nameLabel": "Nombre de la clave", + "namePlaceholder": "Integración de producción", + "revoke": "Revocar clave", + "lastUsed": "Último uso {{date}}", + "neverUsed": "Nunca utilizada", + "keyCreatedTitle": "Clave API creada", + "keyCreatedDescription": "Copia esta clave ahora — no podrás verla de nuevo." + } } }, "models": { @@ -2043,7 +2142,9 @@ "integrations": "連携", "defaultUser": "ユーザー", "notSignedIn": "未サインイン", - "chat": "チャット" + "chat": "チャット", + "inviteTeammate": "Invitar compañero", + "createWorkspace": "Crear espacio de trabajo" }, "chat": { "newChat": "新しいチャット", @@ -2499,5 +2600,53 @@ "you": "あなた", "others": "他の人" } + }, + "workspaces": { + "switcher": { + "personal": "Personal", + "workspaces": "Espacios de trabajo", + "empty": "Aún no hay espacios de trabajo", + "create": "Crear espacio de trabajo…" + }, + "create": { + "title": "Crear espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar, compartir notas y administrar la facturación.", + "nameLabel": "Nombre del espacio de trabajo", + "namePlaceholder": "Acme Corp", + "submit": "Crear espacio de trabajo", + "submitting": "Creando…", + "errorTitle": "No se pudo crear el espacio de trabajo" + }, + "created": { + "title": "Espacio de trabajo creado", + "description": "Ahora eres el propietario de {{name}}." + }, + "invite": { + "title": "Invitar a {{workspace}}", + "description": "Agrega compañeros por correo. Recibirán un enlace para unirse.", + "emailLabel": "Correo electrónico", + "roleLabel": "Rol", + "teamsLabel": "Agregar a equipos", + "submit": "Enviar invitación", + "submitting": "Enviando…", + "sentTitle": "Invitación enviada", + "sentDescription": "Enviamos una invitación a {{email}}.", + "errorTitle": "No se pudo enviar la invitación", + "role": { + "admin": "Administrador", + "member": "Miembro" + } + }, + "accept": { + "title": "Unirse al espacio de trabajo", + "description": "{{inviter}} te invitó a unirte a {{workspace}} como {{role}}.", + "loading": "Cargando invitación…", + "accept": "Aceptar invitación", + "accepting": "Aceptando…", + "signInToAccept": "Inicia sesión para aceptar", + "successTitle": "Te uniste", + "successDescription": "Bienvenido a {{name}}.", + "errorTitle": "No se pudo aceptar la invitación" + } } } diff --git a/src/locales/pt/translation.json b/src/locales/pt/translation.json index 94f4a767b..5f778e0e3 100644 --- a/src/locales/pt/translation.json +++ b/src/locales/pt/translation.json @@ -127,7 +127,17 @@ "private": "Privado", "tap": "Toque", "close": "Fechar", - "dismiss": "Dispensar" + "dismiss": "Dispensar", + "save": "Guardar", + "saving": "Guardando…", + "create": "Crear", + "delete": "Eliminar", + "actions": "Acciones", + "error": "Algo salió mal", + "unknownError": "Ocurrió un error desconocido", + "copy": "Copiar", + "copied": "Copiado", + "done": "Listo" }, "onboarding": { "steps": { @@ -909,6 +919,10 @@ "system": { "description": "Atualizações, armazenamento e ferramentas de desenvolvimento", "label": "Sistema" + }, + "workspace": { + "label": "Espacio de trabajo", + "description": "Miembros, equipos, facturación y claves API" } }, "title": "Configurações" @@ -1576,6 +1590,91 @@ "endpointUrl": "URL do endpoint", "serverUrl": "URL do servidor", "apiKeyOptional": "API Key (opcional)" + }, + "workspace": { + "title": "Espacio de trabajo", + "description": "Colabora con compañeros, comparte notas y administra la facturación en un solo lugar.", + "empty": { + "title": "Crea tu primer espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar con compañeros y compartir notas.", + "create": "Crear espacio de trabajo" + }, + "tab": { + "general": "General", + "members": "Miembros", + "teams": "Equipos", + "billing": "Facturación", + "developer": "Desarrollador" + }, + "role": { + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" + }, + "general": { + "nameLabel": "Nombre del espacio de trabajo", + "slugLabel": "Slug", + "slugHint": "Se usa en URLs y enlaces de invitación. Solo letras minúsculas, dígitos y guiones.", + "saved": "Espacio de trabajo actualizado", + "dangerTitle": "Eliminar espacio de trabajo", + "dangerDescription": "Todas las notas, carpetas e integraciones compartidas se eliminarán. Esto no se puede deshacer.", + "delete": "Eliminar espacio de trabajo", + "confirmTitle": "¿Eliminar {{name}}?", + "confirmDescription": "Esto cancela la suscripción y elimina todos los datos del espacio de trabajo permanentemente." + }, + "members": { + "title": "Miembros", + "description": "{{count}} de {{seats}} asientos utilizados.", + "invite": "Invitar", + "empty": "Aún no hay miembros.", + "roleUpdated": "Rol actualizado", + "promote": "Hacer administrador", + "demote": "Hacer miembro", + "transferOwnership": "Transferir propiedad", + "remove": "Quitar del espacio de trabajo" + }, + "invites": { + "title": "Invitaciones pendientes", + "resend": "Reenviar", + "revoke": "Revocar", + "resent": "Invitación reenviada" + }, + "teams": { + "title": "Equipos", + "description": "Agrupa miembros para compartir con alcance limitado.", + "new": "Nuevo equipo", + "empty": "Aún no hay equipos.", + "createFirst": "Crea tu primer equipo", + "createTitle": "Crear equipo", + "nameLabel": "Nombre del equipo", + "descriptionLabel": "Descripción", + "memberCount": "{{count}} miembros" + }, + "billing": { + "title": "Facturación", + "description": "Plan, asientos y pago.", + "plan": "Plan", + "seats": "Asientos", + "nextInvoice": "Próxima factura", + "manageStripe": "Administrar en Stripe", + "startSubscription": "Iniciar suscripción" + }, + "developer": { + "title": "Claves API", + "description": "Acceso programático limitado a este espacio de trabajo.", + "empty": "Aún no hay claves API. Crea una para integrarte con este espacio de trabajo.", + "new": "Nueva clave", + "create": "Crear clave", + "createTitle": "Crear clave API", + "createDescription": "Elige los permisos que necesita esta clave. Puedes revocarla en cualquier momento.", + "nameLabel": "Nombre de la clave", + "namePlaceholder": "Integración de producción", + "revoke": "Revocar clave", + "lastUsed": "Último uso {{date}}", + "neverUsed": "Nunca utilizada", + "keyCreatedTitle": "Clave API creada", + "keyCreatedDescription": "Copia esta clave ahora — no podrás verla de nuevo." + } } }, "models": { @@ -2043,7 +2142,9 @@ "integrations": "Integrações", "defaultUser": "Usuário", "notSignedIn": "Não conectado", - "chat": "Chat" + "chat": "Chat", + "inviteTeammate": "Invitar compañero", + "createWorkspace": "Crear espacio de trabajo" }, "chat": { "newChat": "Novo chat", @@ -2499,5 +2600,53 @@ "you": "Você", "others": "Outros" } + }, + "workspaces": { + "switcher": { + "personal": "Personal", + "workspaces": "Espacios de trabajo", + "empty": "Aún no hay espacios de trabajo", + "create": "Crear espacio de trabajo…" + }, + "create": { + "title": "Crear espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar, compartir notas y administrar la facturación.", + "nameLabel": "Nombre del espacio de trabajo", + "namePlaceholder": "Acme Corp", + "submit": "Crear espacio de trabajo", + "submitting": "Creando…", + "errorTitle": "No se pudo crear el espacio de trabajo" + }, + "created": { + "title": "Espacio de trabajo creado", + "description": "Ahora eres el propietario de {{name}}." + }, + "invite": { + "title": "Invitar a {{workspace}}", + "description": "Agrega compañeros por correo. Recibirán un enlace para unirse.", + "emailLabel": "Correo electrónico", + "roleLabel": "Rol", + "teamsLabel": "Agregar a equipos", + "submit": "Enviar invitación", + "submitting": "Enviando…", + "sentTitle": "Invitación enviada", + "sentDescription": "Enviamos una invitación a {{email}}.", + "errorTitle": "No se pudo enviar la invitación", + "role": { + "admin": "Administrador", + "member": "Miembro" + } + }, + "accept": { + "title": "Unirse al espacio de trabajo", + "description": "{{inviter}} te invitó a unirte a {{workspace}} como {{role}}.", + "loading": "Cargando invitación…", + "accept": "Aceptar invitación", + "accepting": "Aceptando…", + "signInToAccept": "Inicia sesión para aceptar", + "successTitle": "Te uniste", + "successDescription": "Bienvenido a {{name}}.", + "errorTitle": "No se pudo aceptar la invitación" + } } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 06ecd30b6..992a8aed7 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -155,7 +155,17 @@ "private": "Приватный", "tap": "Нажатие", "close": "Закрыть", - "dismiss": "Скрыть" + "dismiss": "Скрыть", + "save": "Speichern", + "saving": "Speichern…", + "create": "Erstellen", + "delete": "Löschen", + "actions": "Aktionen", + "error": "Etwas ist schiefgelaufen", + "unknownError": "Ein unbekannter Fehler ist aufgetreten", + "copy": "Kopieren", + "copied": "Kopiert", + "done": "Fertig" }, "onboarding": { "steps": { @@ -937,6 +947,10 @@ "system": { "description": "Обновления, хранилище и инструменты разработчика", "label": "Система" + }, + "workspace": { + "label": "Workspace", + "description": "Mitglieder, Teams, Abrechnung und API-Schlüssel" } }, "title": "Настройки" @@ -1604,6 +1618,91 @@ "endpointUrl": "URL эндпоинта", "serverUrl": "URL сервера", "apiKeyOptional": "API-ключ (необязательно)" + }, + "workspace": { + "title": "Workspace", + "description": "Arbeiten Sie zusammen, teilen Sie Notizen und verwalten Sie die Abrechnung an einem Ort.", + "empty": { + "title": "Ersten Workspace erstellen", + "description": "Workspaces ermöglichen die Zusammenarbeit und das Teilen von Notizen.", + "create": "Workspace erstellen" + }, + "tab": { + "general": "Allgemein", + "members": "Mitglieder", + "teams": "Teams", + "billing": "Abrechnung", + "developer": "Entwickler" + }, + "role": { + "owner": "Inhaber", + "admin": "Administrator", + "member": "Mitglied" + }, + "general": { + "nameLabel": "Workspace-Name", + "slugLabel": "Slug", + "slugHint": "Wird in URLs verwendet. Nur Kleinbuchstaben, Ziffern und Bindestriche.", + "saved": "Workspace aktualisiert", + "dangerTitle": "Workspace löschen", + "dangerDescription": "Alle geteilten Notizen, Ordner und Integrationen werden gelöscht. Dies kann nicht rückgängig gemacht werden.", + "delete": "Workspace löschen", + "confirmTitle": "{{name}} löschen?", + "confirmDescription": "Dies kündigt das Abonnement und entfernt alle Workspace-Daten dauerhaft." + }, + "members": { + "title": "Mitglieder", + "description": "{{count}} von {{seats}} Plätzen belegt.", + "invite": "Einladen", + "empty": "Noch keine Mitglieder.", + "roleUpdated": "Rolle aktualisiert", + "promote": "Zum Administrator machen", + "demote": "Zum Mitglied machen", + "transferOwnership": "Eigentum übertragen", + "remove": "Aus Workspace entfernen" + }, + "invites": { + "title": "Ausstehende Einladungen", + "resend": "Erneut senden", + "revoke": "Widerrufen", + "resent": "Einladung erneut gesendet" + }, + "teams": { + "title": "Teams", + "description": "Mitglieder gruppieren für gezieltes Teilen.", + "new": "Neues Team", + "empty": "Noch keine Teams.", + "createFirst": "Erstellen Sie Ihr erstes Team", + "createTitle": "Team erstellen", + "nameLabel": "Team-Name", + "descriptionLabel": "Beschreibung", + "memberCount": "{{count}} Mitglieder" + }, + "billing": { + "title": "Abrechnung", + "description": "Plan, Plätze und Zahlung.", + "plan": "Plan", + "seats": "Plätze", + "nextInvoice": "Nächste Rechnung", + "manageStripe": "In Stripe verwalten", + "startSubscription": "Abonnement starten" + }, + "developer": { + "title": "API-Schlüssel", + "description": "Programmatischer Zugriff auf diesen Workspace.", + "empty": "Noch keine API-Schlüssel. Erstellen Sie einen für Integrationen.", + "new": "Neuer Schlüssel", + "create": "Schlüssel erstellen", + "createTitle": "API-Schlüssel erstellen", + "createDescription": "Wählen Sie die Berechtigungen. Sie können den Schlüssel jederzeit widerrufen.", + "nameLabel": "Schlüsselname", + "namePlaceholder": "Produktionsintegration", + "revoke": "Schlüssel widerrufen", + "lastUsed": "Zuletzt verwendet {{date}}", + "neverUsed": "Nie verwendet", + "keyCreatedTitle": "API-Schlüssel erstellt", + "keyCreatedDescription": "Kopieren Sie diesen Schlüssel jetzt — Sie können ihn nicht erneut sehen." + } } }, "models": { @@ -2045,7 +2144,9 @@ "integrations": "Интеграции", "defaultUser": "Пользователь", "notSignedIn": "Не авторизован", - "chat": "Чат" + "chat": "Чат", + "inviteTeammate": "Teammitglied einladen", + "createWorkspace": "Workspace erstellen" }, "chat": { "newChat": "Новый чат", @@ -2501,5 +2602,53 @@ "you": "Вы", "others": "Другие" } + }, + "workspaces": { + "switcher": { + "personal": "Persönlich", + "workspaces": "Workspaces", + "empty": "Noch keine Workspaces", + "create": "Workspace erstellen…" + }, + "create": { + "title": "Workspace erstellen", + "description": "Workspaces ermöglichen Zusammenarbeit, geteilte Notizen und gemeinsame Abrechnung.", + "nameLabel": "Workspace-Name", + "namePlaceholder": "Acme Corp", + "submit": "Workspace erstellen", + "submitting": "Erstelle…", + "errorTitle": "Workspace konnte nicht erstellt werden" + }, + "created": { + "title": "Workspace erstellt", + "description": "Sie sind nun Inhaber von {{name}}." + }, + "invite": { + "title": "Zu {{workspace}} einladen", + "description": "Fügen Sie Teammitglieder per E-Mail hinzu. Sie erhalten einen Beitrittslink.", + "emailLabel": "E-Mail", + "roleLabel": "Rolle", + "teamsLabel": "Zu Teams hinzufügen", + "submit": "Einladung senden", + "submitting": "Sende…", + "sentTitle": "Einladung gesendet", + "sentDescription": "Einladung an {{email}} gesendet.", + "errorTitle": "Einladung konnte nicht gesendet werden", + "role": { + "admin": "Administrator", + "member": "Mitglied" + } + }, + "accept": { + "title": "Workspace beitreten", + "description": "{{inviter}} hat Sie eingeladen, {{workspace}} als {{role}} beizutreten.", + "loading": "Lade Einladung…", + "accept": "Einladung annehmen", + "accepting": "Annehmen…", + "signInToAccept": "Anmelden zum Annehmen", + "successTitle": "Beigetreten", + "successDescription": "Willkommen bei {{name}}.", + "errorTitle": "Einladung konnte nicht angenommen werden" + } } } diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index cf24c0f9e..d3e404487 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -155,7 +155,17 @@ "private": "隐私", "tap": "点按", "close": "关闭", - "dismiss": "忽略" + "dismiss": "忽略", + "save": "Guardar", + "saving": "Guardando…", + "create": "Crear", + "delete": "Eliminar", + "actions": "Acciones", + "error": "Algo salió mal", + "unknownError": "Ocurrió un error desconocido", + "copy": "Copiar", + "copied": "Copiado", + "done": "Listo" }, "onboarding": { "steps": { @@ -937,6 +947,10 @@ "system": { "description": "更新、存储与开发者工具", "label": "系统" + }, + "workspace": { + "label": "Espacio de trabajo", + "description": "Miembros, equipos, facturación y claves API" } }, "title": "设置" @@ -1058,7 +1072,12 @@ "annualEquivalent": "$16.67", "includesPrefix": "所有 Pro 功能 +", "badge": "最受欢迎", - "features": ["无限会议录制", "代理模式", "与您的数据对话", "优先支持"], + "features": [ + "无限会议录制", + "代理模式", + "与您的数据对话", + "优先支持" + ], "cta": "开始使用", "switchToAnnual": "切换为年付" }, @@ -1599,6 +1618,91 @@ "endpointUrl": "端点 URL", "serverUrl": "服务器 URL", "apiKeyOptional": "API Key(可选)" + }, + "workspace": { + "title": "Espacio de trabajo", + "description": "Colabora con compañeros, comparte notas y administra la facturación en un solo lugar.", + "empty": { + "title": "Crea tu primer espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar con compañeros y compartir notas.", + "create": "Crear espacio de trabajo" + }, + "tab": { + "general": "General", + "members": "Miembros", + "teams": "Equipos", + "billing": "Facturación", + "developer": "Desarrollador" + }, + "role": { + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" + }, + "general": { + "nameLabel": "Nombre del espacio de trabajo", + "slugLabel": "Slug", + "slugHint": "Se usa en URLs y enlaces de invitación. Solo letras minúsculas, dígitos y guiones.", + "saved": "Espacio de trabajo actualizado", + "dangerTitle": "Eliminar espacio de trabajo", + "dangerDescription": "Todas las notas, carpetas e integraciones compartidas se eliminarán. Esto no se puede deshacer.", + "delete": "Eliminar espacio de trabajo", + "confirmTitle": "¿Eliminar {{name}}?", + "confirmDescription": "Esto cancela la suscripción y elimina todos los datos del espacio de trabajo permanentemente." + }, + "members": { + "title": "Miembros", + "description": "{{count}} de {{seats}} asientos utilizados.", + "invite": "Invitar", + "empty": "Aún no hay miembros.", + "roleUpdated": "Rol actualizado", + "promote": "Hacer administrador", + "demote": "Hacer miembro", + "transferOwnership": "Transferir propiedad", + "remove": "Quitar del espacio de trabajo" + }, + "invites": { + "title": "Invitaciones pendientes", + "resend": "Reenviar", + "revoke": "Revocar", + "resent": "Invitación reenviada" + }, + "teams": { + "title": "Equipos", + "description": "Agrupa miembros para compartir con alcance limitado.", + "new": "Nuevo equipo", + "empty": "Aún no hay equipos.", + "createFirst": "Crea tu primer equipo", + "createTitle": "Crear equipo", + "nameLabel": "Nombre del equipo", + "descriptionLabel": "Descripción", + "memberCount": "{{count}} miembros" + }, + "billing": { + "title": "Facturación", + "description": "Plan, asientos y pago.", + "plan": "Plan", + "seats": "Asientos", + "nextInvoice": "Próxima factura", + "manageStripe": "Administrar en Stripe", + "startSubscription": "Iniciar suscripción" + }, + "developer": { + "title": "Claves API", + "description": "Acceso programático limitado a este espacio de trabajo.", + "empty": "Aún no hay claves API. Crea una para integrarte con este espacio de trabajo.", + "new": "Nueva clave", + "create": "Crear clave", + "createTitle": "Crear clave API", + "createDescription": "Elige los permisos que necesita esta clave. Puedes revocarla en cualquier momento.", + "nameLabel": "Nombre de la clave", + "namePlaceholder": "Integración de producción", + "revoke": "Revocar clave", + "lastUsed": "Último uso {{date}}", + "neverUsed": "Nunca utilizada", + "keyCreatedTitle": "Clave API creada", + "keyCreatedDescription": "Copia esta clave ahora — no podrás verla de nuevo." + } } }, "models": { @@ -2038,7 +2142,9 @@ "integrations": "集成", "defaultUser": "用户", "notSignedIn": "未登录", - "chat": "聊天" + "chat": "聊天", + "inviteTeammate": "Invitar compañero", + "createWorkspace": "Crear espacio de trabajo" }, "chat": { "newChat": "新对话", @@ -2494,5 +2600,53 @@ "you": "你", "others": "其他人" } + }, + "workspaces": { + "switcher": { + "personal": "Personal", + "workspaces": "Espacios de trabajo", + "empty": "Aún no hay espacios de trabajo", + "create": "Crear espacio de trabajo…" + }, + "create": { + "title": "Crear espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar, compartir notas y administrar la facturación.", + "nameLabel": "Nombre del espacio de trabajo", + "namePlaceholder": "Acme Corp", + "submit": "Crear espacio de trabajo", + "submitting": "Creando…", + "errorTitle": "No se pudo crear el espacio de trabajo" + }, + "created": { + "title": "Espacio de trabajo creado", + "description": "Ahora eres el propietario de {{name}}." + }, + "invite": { + "title": "Invitar a {{workspace}}", + "description": "Agrega compañeros por correo. Recibirán un enlace para unirse.", + "emailLabel": "Correo electrónico", + "roleLabel": "Rol", + "teamsLabel": "Agregar a equipos", + "submit": "Enviar invitación", + "submitting": "Enviando…", + "sentTitle": "Invitación enviada", + "sentDescription": "Enviamos una invitación a {{email}}.", + "errorTitle": "No se pudo enviar la invitación", + "role": { + "admin": "Administrador", + "member": "Miembro" + } + }, + "accept": { + "title": "Unirse al espacio de trabajo", + "description": "{{inviter}} te invitó a unirte a {{workspace}} como {{role}}.", + "loading": "Cargando invitación…", + "accept": "Aceptar invitación", + "accepting": "Aceptando…", + "signInToAccept": "Inicia sesión para aceptar", + "successTitle": "Te uniste", + "successDescription": "Bienvenido a {{name}}.", + "errorTitle": "No se pudo aceptar la invitación" + } } } diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index abee22d57..013298998 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -155,7 +155,17 @@ "private": "私密", "tap": "點按", "close": "關閉", - "dismiss": "忽略" + "dismiss": "忽略", + "save": "Guardar", + "saving": "Guardando…", + "create": "Crear", + "delete": "Eliminar", + "actions": "Acciones", + "error": "Algo salió mal", + "unknownError": "Ocurrió un error desconocido", + "copy": "Copiar", + "copied": "Copiado", + "done": "Listo" }, "onboarding": { "steps": { @@ -937,6 +947,10 @@ "system": { "description": "更新、儲存與開發者工具", "label": "系統" + }, + "workspace": { + "label": "Espacio de trabajo", + "description": "Miembros, equipos, facturación y claves API" } }, "title": "設定" @@ -1058,7 +1072,12 @@ "annualEquivalent": "$16.67", "includesPrefix": "所有 Pro 功能 +", "badge": "最熱門", - "features": ["無限會議錄製", "代理模式", "與您的資料對話", "優先支援"], + "features": [ + "無限會議錄製", + "代理模式", + "與您的資料對話", + "優先支援" + ], "cta": "開始使用", "switchToAnnual": "切換為年付" }, @@ -1599,6 +1618,91 @@ "endpointUrl": "端點 URL", "serverUrl": "伺服器 URL", "apiKeyOptional": "API Key(選填)" + }, + "workspace": { + "title": "Espacio de trabajo", + "description": "Colabora con compañeros, comparte notas y administra la facturación en un solo lugar.", + "empty": { + "title": "Crea tu primer espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar con compañeros y compartir notas.", + "create": "Crear espacio de trabajo" + }, + "tab": { + "general": "General", + "members": "Miembros", + "teams": "Equipos", + "billing": "Facturación", + "developer": "Desarrollador" + }, + "role": { + "owner": "Propietario", + "admin": "Administrador", + "member": "Miembro" + }, + "general": { + "nameLabel": "Nombre del espacio de trabajo", + "slugLabel": "Slug", + "slugHint": "Se usa en URLs y enlaces de invitación. Solo letras minúsculas, dígitos y guiones.", + "saved": "Espacio de trabajo actualizado", + "dangerTitle": "Eliminar espacio de trabajo", + "dangerDescription": "Todas las notas, carpetas e integraciones compartidas se eliminarán. Esto no se puede deshacer.", + "delete": "Eliminar espacio de trabajo", + "confirmTitle": "¿Eliminar {{name}}?", + "confirmDescription": "Esto cancela la suscripción y elimina todos los datos del espacio de trabajo permanentemente." + }, + "members": { + "title": "Miembros", + "description": "{{count}} de {{seats}} asientos utilizados.", + "invite": "Invitar", + "empty": "Aún no hay miembros.", + "roleUpdated": "Rol actualizado", + "promote": "Hacer administrador", + "demote": "Hacer miembro", + "transferOwnership": "Transferir propiedad", + "remove": "Quitar del espacio de trabajo" + }, + "invites": { + "title": "Invitaciones pendientes", + "resend": "Reenviar", + "revoke": "Revocar", + "resent": "Invitación reenviada" + }, + "teams": { + "title": "Equipos", + "description": "Agrupa miembros para compartir con alcance limitado.", + "new": "Nuevo equipo", + "empty": "Aún no hay equipos.", + "createFirst": "Crea tu primer equipo", + "createTitle": "Crear equipo", + "nameLabel": "Nombre del equipo", + "descriptionLabel": "Descripción", + "memberCount": "{{count}} miembros" + }, + "billing": { + "title": "Facturación", + "description": "Plan, asientos y pago.", + "plan": "Plan", + "seats": "Asientos", + "nextInvoice": "Próxima factura", + "manageStripe": "Administrar en Stripe", + "startSubscription": "Iniciar suscripción" + }, + "developer": { + "title": "Claves API", + "description": "Acceso programático limitado a este espacio de trabajo.", + "empty": "Aún no hay claves API. Crea una para integrarte con este espacio de trabajo.", + "new": "Nueva clave", + "create": "Crear clave", + "createTitle": "Crear clave API", + "createDescription": "Elige los permisos que necesita esta clave. Puedes revocarla en cualquier momento.", + "nameLabel": "Nombre de la clave", + "namePlaceholder": "Integración de producción", + "revoke": "Revocar clave", + "lastUsed": "Último uso {{date}}", + "neverUsed": "Nunca utilizada", + "keyCreatedTitle": "Clave API creada", + "keyCreatedDescription": "Copia esta clave ahora — no podrás verla de nuevo." + } } }, "models": { @@ -2038,7 +2142,9 @@ "integrations": "整合", "defaultUser": "使用者", "notSignedIn": "尚未登入", - "chat": "聊天" + "chat": "聊天", + "inviteTeammate": "Invitar compañero", + "createWorkspace": "Crear espacio de trabajo" }, "chat": { "newChat": "新對話", @@ -2494,5 +2600,53 @@ "you": "你", "others": "其他人" } + }, + "workspaces": { + "switcher": { + "personal": "Personal", + "workspaces": "Espacios de trabajo", + "empty": "Aún no hay espacios de trabajo", + "create": "Crear espacio de trabajo…" + }, + "create": { + "title": "Crear espacio de trabajo", + "description": "Los espacios de trabajo te permiten colaborar, compartir notas y administrar la facturación.", + "nameLabel": "Nombre del espacio de trabajo", + "namePlaceholder": "Acme Corp", + "submit": "Crear espacio de trabajo", + "submitting": "Creando…", + "errorTitle": "No se pudo crear el espacio de trabajo" + }, + "created": { + "title": "Espacio de trabajo creado", + "description": "Ahora eres el propietario de {{name}}." + }, + "invite": { + "title": "Invitar a {{workspace}}", + "description": "Agrega compañeros por correo. Recibirán un enlace para unirse.", + "emailLabel": "Correo electrónico", + "roleLabel": "Rol", + "teamsLabel": "Agregar a equipos", + "submit": "Enviar invitación", + "submitting": "Enviando…", + "sentTitle": "Invitación enviada", + "sentDescription": "Enviamos una invitación a {{email}}.", + "errorTitle": "No se pudo enviar la invitación", + "role": { + "admin": "Administrador", + "member": "Miembro" + } + }, + "accept": { + "title": "Unirse al espacio de trabajo", + "description": "{{inviter}} te invitó a unirte a {{workspace}} como {{role}}.", + "loading": "Cargando invitación…", + "accept": "Aceptar invitación", + "accepting": "Aceptando…", + "signInToAccept": "Inicia sesión para aceptar", + "successTitle": "Te uniste", + "successDescription": "Bienvenido a {{name}}.", + "errorTitle": "No se pudo aceptar la invitación" + } } } diff --git a/src/services/InvitationsService.ts b/src/services/InvitationsService.ts new file mode 100644 index 000000000..165ea7e94 --- /dev/null +++ b/src/services/InvitationsService.ts @@ -0,0 +1,55 @@ +import { cloudGet, cloudPost, cloudDelete } from "./cloudApi.js"; +import type { WorkspaceInvitation, InvitationPreview } from "../types/electron"; + +interface DataWrap { + data: T; +} + +async function list(workspaceId: string): Promise { + const res = await cloudGet>( + `/api/workspaces/${workspaceId}/invitations` + ); + return res.data; +} + +async function send( + workspaceId: string, + input: { email: string; role?: "admin" | "member"; team_ids?: string[] } +): Promise { + const res = await cloudPost>( + `/api/workspaces/${workspaceId}/invitations`, + input + ); + return res.data; +} + +async function revoke(workspaceId: string, invitationId: string): Promise { + await cloudDelete(`/api/workspaces/${workspaceId}/invitations/${invitationId}`); +} + +async function resend(workspaceId: string, invitationId: string): Promise { + await cloudPost(`/api/workspaces/${workspaceId}/invitations/${invitationId}`); +} + +async function preview(token: string): Promise { + const res = await cloudGet>( + `/api/invitations/${encodeURIComponent(token)}` + ); + return res.data; +} + +async function accept(token: string): Promise<{ workspace_id: string; role: string }> { + const res = await cloudPost>( + `/api/invitations/${encodeURIComponent(token)}/accept` + ); + return res.data; +} + +export const InvitationsService = { + list, + send, + revoke, + resend, + preview, + accept, +}; diff --git a/src/services/TeamsService.ts b/src/services/TeamsService.ts new file mode 100644 index 000000000..db0f3ef5f --- /dev/null +++ b/src/services/TeamsService.ts @@ -0,0 +1,58 @@ +import { cloudGet, cloudPost, cloudPatch, cloudDelete } from "./cloudApi.js"; +import type { Team, TeamMember } from "../types/electron"; + +interface DataWrap { + data: T; +} + +async function list(workspaceId: string): Promise { + const res = await cloudGet>(`/api/workspaces/${workspaceId}/teams`); + return res.data; +} + +async function create( + workspaceId: string, + input: { name: string; description?: string } +): Promise { + const res = await cloudPost>(`/api/workspaces/${workspaceId}/teams`, input); + return res.data; +} + +async function update( + teamId: string, + patch: { name?: string; description?: string } +): Promise { + const res = await cloudPatch>(`/api/teams/${teamId}`, patch); + return res.data; +} + +async function remove(teamId: string): Promise { + await cloudDelete(`/api/teams/${teamId}`); +} + +async function listMembers(teamId: string): Promise { + const res = await cloudGet>(`/api/teams/${teamId}/members`); + return res.data; +} + +async function addMember( + teamId: string, + userId: string, + role: "admin" | "member" = "member" +): Promise { + await cloudPost(`/api/teams/${teamId}/members`, { user_id: userId, role }); +} + +async function removeMember(teamId: string, userId: string): Promise { + await cloudDelete(`/api/teams/${teamId}/members/${userId}`); +} + +export const TeamsService = { + list, + create, + update, + remove, + listMembers, + addMember, + removeMember, +}; diff --git a/src/services/WorkspaceApiKeysService.ts b/src/services/WorkspaceApiKeysService.ts new file mode 100644 index 000000000..c4ff1b332 --- /dev/null +++ b/src/services/WorkspaceApiKeysService.ts @@ -0,0 +1,34 @@ +import { cloudGet, cloudPost, cloudDelete } from "./cloudApi.js"; +import type { WorkspaceApiKey, NewWorkspaceApiKey } from "../types/electron"; + +interface DataWrap { + data: T; +} + +async function list(workspaceId: string): Promise { + const res = await cloudGet>( + `/api/workspaces/${workspaceId}/api-keys` + ); + return res.data; +} + +async function create( + workspaceId: string, + input: { name: string; scopes: string[]; expires_in_days?: number; description?: string } +): Promise { + const res = await cloudPost>( + `/api/workspaces/${workspaceId}/api-keys`, + input + ); + return res.data; +} + +async function revoke(workspaceId: string, keyId: string): Promise { + await cloudDelete(`/api/workspaces/${workspaceId}/api-keys/${keyId}`); +} + +export const WorkspaceApiKeysService = { + list, + create, + revoke, +}; diff --git a/src/services/WorkspacesService.ts b/src/services/WorkspacesService.ts new file mode 100644 index 000000000..10bec45ef --- /dev/null +++ b/src/services/WorkspacesService.ts @@ -0,0 +1,96 @@ +import { cloudGet, cloudPost, cloudPatch, cloudDelete } from "./cloudApi.js"; +import type { Workspace, WorkspaceMember } from "../types/electron"; + +interface DataWrap { + data: T; +} + +async function list(): Promise { + const res = await cloudGet>("/api/workspaces"); + return res.data; +} + +async function create(name: string): Promise { + const res = await cloudPost>("/api/workspaces", { name }); + return res.data; +} + +async function get(workspaceId: string): Promise { + const res = await cloudGet>(`/api/workspaces/${workspaceId}`); + return res.data; +} + +async function update( + workspaceId: string, + patch: { name?: string; slug?: string } +): Promise { + const res = await cloudPatch>(`/api/workspaces/${workspaceId}`, patch); + return res.data; +} + +async function remove(workspaceId: string): Promise { + await cloudDelete(`/api/workspaces/${workspaceId}`); +} + +async function listMembers(workspaceId: string): Promise { + const res = await cloudGet>( + `/api/workspaces/${workspaceId}/members` + ); + return res.data; +} + +async function updateMemberRole( + workspaceId: string, + userId: string, + role: "owner" | "admin" | "member" +): Promise { + await cloudPatch(`/api/workspaces/${workspaceId}/members/${userId}`, { role }); +} + +async function removeMember(workspaceId: string, userId: string): Promise { + await cloudDelete(`/api/workspaces/${workspaceId}/members/${userId}`); +} + +async function billingCheckout( + workspaceId: string, + interval: "monthly" | "annual" = "monthly" +): Promise { + const res = await cloudPost>( + `/api/workspaces/${workspaceId}/billing/checkout`, + { interval } + ); + return res.data.url; +} + +async function billingPortal(workspaceId: string): Promise { + const res = await cloudPost>( + `/api/workspaces/${workspaceId}/billing/portal` + ); + return res.data.url; +} + +async function previewSeats( + workspaceId: string, + additionalSeats: number +): Promise<{ next_quantity: number; amount_due: number; currency: string }> { + const res = await cloudPost< + DataWrap<{ next_quantity: number; amount_due: number; currency: string }> + >(`/api/workspaces/${workspaceId}/billing/preview-seats`, { + additional_seats: additionalSeats, + }); + return res.data; +} + +export const WorkspacesService = { + list, + create, + get, + update, + remove, + listMembers, + updateMemberRole, + removeMember, + billingCheckout, + billingPortal, + previewSeats, +}; diff --git a/src/stores/workspaceStore.ts b/src/stores/workspaceStore.ts new file mode 100644 index 000000000..88aab6639 --- /dev/null +++ b/src/stores/workspaceStore.ts @@ -0,0 +1,106 @@ +import { create } from "zustand"; +import type { Workspace, WorkspaceMember, Team } from "../types/electron"; +import { WorkspacesService } from "../services/WorkspacesService"; +import { TeamsService } from "../services/TeamsService"; +import logger from "../utils/logger"; + +interface WorkspaceState { + workspaces: Workspace[]; + loaded: boolean; + loading: boolean; + activeWorkspaceId: string | null; + members: WorkspaceMember[]; + teams: Team[]; + + setActiveWorkspaceId: (id: string | null) => void; + refresh: () => Promise; + createWorkspace: (name: string) => Promise; + refreshMembers: (workspaceId: string) => Promise; + refreshTeams: (workspaceId: string) => Promise; + current: () => Workspace | null; +} + +const ACTIVE_WORKSPACE_KEY = "activeWorkspaceId"; + +function readActiveWorkspaceId(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(ACTIVE_WORKSPACE_KEY); +} + +function writeActiveWorkspaceId(id: string | null): void { + if (typeof window === "undefined") return; + if (id) localStorage.setItem(ACTIVE_WORKSPACE_KEY, id); + else localStorage.removeItem(ACTIVE_WORKSPACE_KEY); +} + +export const useWorkspaceStore = create((set, get) => ({ + workspaces: [], + loaded: false, + loading: false, + activeWorkspaceId: readActiveWorkspaceId(), + members: [], + teams: [], + + setActiveWorkspaceId: (id) => { + writeActiveWorkspaceId(id); + set({ activeWorkspaceId: id, members: [], teams: [] }); + }, + + refresh: async () => { + if (get().loading) return; + set({ loading: true }); + try { + const workspaces = await WorkspacesService.list(); + const activeId = get().activeWorkspaceId; + const stillValid = activeId && workspaces.some((w) => w.id === activeId); + set({ + workspaces, + loaded: true, + loading: false, + activeWorkspaceId: stillValid ? activeId : null, + }); + if (!stillValid && activeId) writeActiveWorkspaceId(null); + } catch (error) { + logger.error("Failed to load workspaces", { error: (error as Error).message }, "workspaces"); + set({ loading: false, loaded: true }); + } + }, + + createWorkspace: async (name) => { + const workspace = await WorkspacesService.create(name); + set((s) => ({ workspaces: [...s.workspaces, workspace] })); + return workspace; + }, + + refreshMembers: async (workspaceId) => { + try { + const members = await WorkspacesService.listMembers(workspaceId); + set({ members }); + } catch (error) { + logger.error( + "Failed to load workspace members", + { error: (error as Error).message }, + "workspaces" + ); + } + }, + + refreshTeams: async (workspaceId) => { + try { + const teams = await TeamsService.list(workspaceId); + set({ teams }); + } catch (error) { + logger.error( + "Failed to load workspace teams", + { error: (error as Error).message }, + "workspaces" + ); + } + }, + + current: () => { + const { activeWorkspaceId, workspaces } = get(); + if (!activeWorkspaceId) return null; + return workspaces.find((w) => w.id === activeWorkspaceId) ?? null; + }, +})); diff --git a/src/types/electron.ts b/src/types/electron.ts index 805a7bc74..579de1dff 100644 --- a/src/types/electron.ts +++ b/src/types/electron.ts @@ -60,6 +60,8 @@ export interface NoteItem { client_note_id: string; sync_status: "synced" | "pending" | "error"; deleted_at: string | null; + workspace_id?: string | null; + team_id?: string | null; } export interface FolderItem { @@ -73,6 +75,99 @@ export interface FolderItem { cloud_id: string | null; sync_status: "synced" | "pending" | "error"; deleted_at: string | null; + workspace_id?: string | null; + team_id?: string | null; +} + +export type WorkspaceRole = "owner" | "admin" | "member"; +export type TeamRole = "admin" | "member"; + +export interface Workspace { + id: string; + name: string; + slug: string; + created_by_user_id: string; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + plan: string; + status: string; + trial_ends_at: string | null; + current_period_end: string | null; + cancel_at_period_end: boolean; + seats: number; + created_at: string; + updated_at: string; + role: WorkspaceRole; +} + +export interface WorkspaceMember { + user_id: string; + role: WorkspaceRole; + joined_at: string; + email: string; + name: string | null; + image: string | null; +} + +export interface Team { + id: string; + workspace_id: string; + name: string; + slug: string; + description: string | null; + created_at: string; + updated_at: string; + member_count?: number; +} + +export interface TeamMember { + user_id: string; + role: TeamRole; + joined_at: string; + email: string; + name: string | null; + image: string | null; +} + +export interface WorkspaceInvitation { + id: string; + email: string; + workspace_role: TeamRole; + team_ids: string[]; + invited_by_user_id: string; + expires_at: string; + created_at: string; + accepted_at: string | null; + revoked_at: string | null; +} + +export interface InvitationPreview { + id: string; + email: string; + workspace_role: TeamRole; + team_ids: string[]; + expires_at: string; + workspace_id: string; + workspace_name: string; + workspace_slug: string; + inviter_name: string | null; + inviter_email: string | null; +} + +export interface WorkspaceApiKey { + id: string; + name: string; + key_prefix: string; + scopes: string[]; + last_used_at: string | null; + expires_at: string | null; + created_at: string; + created_by_user_id: string | null; + description: string | null; +} + +export interface NewWorkspaceApiKey extends WorkspaceApiKey { + key: string; } export interface ActionItem { @@ -1119,6 +1214,9 @@ declare global { callback: (data: { wordsUsed: number; limit: number }) => void ) => () => void; + // Workspace invitation deep link + onWorkspaceInvitationToken?: (callback: (token: string) => void) => () => void; + // AssemblyAI Streaming assemblyAiStreamingWarmup?: (options?: { sampleRate?: number;