diff --git a/src/components/layout/MainPanel.tsx b/src/components/layout/MainPanel.tsx index 78524db3..8836df5b 100644 --- a/src/components/layout/MainPanel.tsx +++ b/src/components/layout/MainPanel.tsx @@ -82,6 +82,14 @@ function TeamVaultState({ title: t("layout.mainPanel.teamVault.forbiddenTitle"), body: t("layout.mainPanel.teamVault.forbiddenBody"), }, + // Member has joined the team but no vault owner has distributed a key yet + // (issue #41). Distinct from a hard error — a key-holder self-heals this on + // their next sync, so present it as a benign waiting state, not a failure. + not_found: { + icon: "lucide:clock", + title: t("layout.mainPanel.teamVault.waitingForAccessTitle"), + body: t("layout.mainPanel.teamVault.waitingForAccessBody"), + }, payment_required: { icon: "lucide:credit-card", title: t("layout.mainPanel.teamVault.paymentRequiredTitle"), @@ -191,6 +199,7 @@ export default function MainPanel() { (teamVaultStatus === "offline" || teamVaultStatus === "forbidden" || teamVaultStatus === "payment_required" || + teamVaultStatus === "not_found" || teamVaultStatus === "error") && !homeView; const showSplitWorkspace = activeNav === "terminal" && splitTabActive && !sftpPanelOpen; diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d04edb6e..eaa71ba2 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -73,10 +73,8 @@ "noConnectionsSelected": "No connections selected", "serialPortConfigNotFound": "Serial port configuration not found", "connectionConfigNotFound": "Connection config not found", - "failedToFetchAuditLogs": "Failed to fetch audit logs: {{status}}", "failedToExportAuditLogs": "Failed to export audit logs: {{status}}", - "serverUnreachableTimeout": "Server unreachable (timeout) — check your internet connection and server URL", "networkError": "Network error — {{message}}", "emailAlreadyRegistered": "Email already registered", @@ -99,7 +97,6 @@ "emailInUse": "Email is already in use", "incorrectPassword": "Incorrect password", "emailUpdateFailed": "Email update failed: {{status}}", - "userNotFoundVoltiusAccount": "User not found — they must have a Voltius account", "userNotFound": "User not found", "cannotAddYourself": "Cannot add yourself", @@ -112,6 +109,7 @@ "noPermissionInviteMembers": "You don't have permission to invite members", "failedToCreateTeam": "Failed to create team: {{status}}", "failedToListMembers": "Failed to list members: {{status}}", + "failedToListVaultKeyHolders": "Failed to list vault key holders: {{status}}", "failedToAddMember": "Failed to add member: {{status}}", "failedToRemoveMember": "Failed to remove member: {{status}}", "failedToAssignRole": "Failed to assign role: {{status}}", @@ -124,7 +122,6 @@ "failedToRevokeInvitation": "Failed to revoke invitation: {{status}}", "failedToAcceptInvitation": "Failed to accept invitation: {{status}}", "failedToDeclineInvitation": "Failed to decline invitation: {{status}}", - "noPermissionTeamVaultOp": "You do not have permission for this team vault operation", "teamVaultRequiresSubscription": "Team vault requires an active Teams or Business subscription", "failedToListTeamObjects": "Failed to list team objects: {{status}}", @@ -135,16 +132,13 @@ "failedToDeleteTeamObjectPref": "Failed to delete team object pref: {{status}}", "failedToListTeamSecrets": "Failed to list team secrets: {{status}}", "failedToSaveTeamSecret": "Failed to save team secret: {{status}}", - "keyFetchFailed": "Key fetch failed: {{error}}", "failedToUploadVaultKeys": "Failed to upload vault keys: {{status}}", "failedToDistributeVaultKey": "Failed to distribute vault key: {{status}}", "failedToSaveTeamData": "Failed to save team data: {{status}}", - "failedToCreateSession": "Failed to create session: {{status}}", "failedToCreateInviteLinkSession": "Failed to create invite link session: {{status}}", "failedToGetSessionKey": "Failed to get session key: {{status}}", - "invalidJson": "Invalid JSON", "notVoltiusExportBundle": "Not a Voltius export bundle (expected { version: 1, ... })", "invalidEncryptedFile": "Invalid encrypted file", diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index 06174d6a..e644868a 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -61,6 +61,8 @@ "offlineBody": "Team vaults require an internet connection. Your personal vault is available offline.", "forbiddenTitle": "Access revoked", "forbiddenBody": "You no longer have access to this team vault. Contact the team owner.", + "waitingForAccessTitle": "Waiting for access", + "waitingForAccessBody": "A team vault owner needs to grant you access. This happens automatically the next time an owner is online.", "paymentRequiredTitle": "Team vault unavailable", "paymentRequiredBodyOwner": "Your subscription has ended. Team vaults require an active plan.", "paymentRequiredBodyMember": "The owner's subscription has ended. Team vaults require an active plan.", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index dc1c01bb..2983f3c5 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -73,10 +73,8 @@ "noConnectionsSelected": "Aucune connexion sélectionnée", "serialPortConfigNotFound": "Configuration du port série introuvable", "connectionConfigNotFound": "Configuration de connexion introuvable", - "failedToFetchAuditLogs": "Échec de la récupération des journaux d'audit : {{status}}", "failedToExportAuditLogs": "Échec de l'exportation des journaux d'audit : {{status}}", - "serverUnreachableTimeout": "Serveur injoignable (délai dépassé) — vérifiez votre connexion internet et l'URL du serveur", "networkError": "Erreur réseau — {{message}}", "emailAlreadyRegistered": "Cet e-mail est déjà enregistré", @@ -99,7 +97,6 @@ "emailInUse": "Cet e-mail est déjà utilisé", "incorrectPassword": "Mot de passe incorrect", "emailUpdateFailed": "Échec de la mise à jour de l'e-mail : {{status}}", - "userNotFoundVoltiusAccount": "Utilisateur introuvable — il doit posséder un compte Voltius", "userNotFound": "Utilisateur introuvable", "cannotAddYourself": "Impossible de s'ajouter soi-même", @@ -112,6 +109,7 @@ "noPermissionInviteMembers": "Vous n'avez pas la permission d'inviter des membres", "failedToCreateTeam": "Échec de la création de l'équipe : {{status}}", "failedToListMembers": "Échec du chargement des membres : {{status}}", + "failedToListVaultKeyHolders": "Échec du chargement des détenteurs de clé de coffre : {{status}}", "failedToAddMember": "Échec de l'ajout du membre : {{status}}", "failedToRemoveMember": "Échec de la suppression du membre : {{status}}", "failedToAssignRole": "Échec de l'attribution du rôle : {{status}}", @@ -124,7 +122,6 @@ "failedToRevokeInvitation": "Échec de la révocation de l'invitation : {{status}}", "failedToAcceptInvitation": "Échec de l'acceptation de l'invitation : {{status}}", "failedToDeclineInvitation": "Échec du refus de l'invitation : {{status}}", - "noPermissionTeamVaultOp": "Vous n'avez pas la permission d'effectuer cette opération sur le coffre d'équipe", "teamVaultRequiresSubscription": "Le coffre d'équipe nécessite un abonnement Teams ou Business actif", "failedToListTeamObjects": "Échec du chargement des objets d'équipe : {{status}}", @@ -135,16 +132,13 @@ "failedToDeleteTeamObjectPref": "Échec de la suppression de la préférence d'objet d'équipe : {{status}}", "failedToListTeamSecrets": "Échec du chargement des secrets d'équipe : {{status}}", "failedToSaveTeamSecret": "Échec de l'enregistrement du secret d'équipe : {{status}}", - "keyFetchFailed": "Échec de la récupération de la clé : {{error}}", "failedToUploadVaultKeys": "Échec de l'envoi des clés de coffre : {{status}}", "failedToDistributeVaultKey": "Échec de la distribution de la clé de coffre : {{status}}", "failedToSaveTeamData": "Échec de l'enregistrement des données d'équipe : {{status}}", - "failedToCreateSession": "Échec de la création de la session : {{status}}", "failedToCreateInviteLinkSession": "Échec de la création du lien d'invitation de session : {{status}}", "failedToGetSessionKey": "Échec de la récupération de la clé de session : {{status}}", - "invalidJson": "JSON invalide", "notVoltiusExportBundle": "Ce n'est pas un fichier d'export Voltius (attendu : { version: 1, ... })", "invalidEncryptedFile": "Fichier chiffré invalide", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index 8b48d0e0..6178cdcf 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -61,6 +61,8 @@ "offlineBody": "Les coffres d'équipe nécessitent une connexion internet. Votre coffre personnel reste disponible hors ligne.", "forbiddenTitle": "Accès révoqué", "forbiddenBody": "Vous n'avez plus accès à ce coffre d'équipe. Contactez le propriétaire de l'équipe.", + "waitingForAccessTitle": "En attente d'accès", + "waitingForAccessBody": "Un propriétaire du coffre d'équipe doit vous accorder l'accès. Cela se fait automatiquement à la prochaine connexion d'un propriétaire.", "paymentRequiredTitle": "Coffre d'équipe indisponible", "paymentRequiredBodyOwner": "Votre abonnement a expiré. Les coffres d'équipe nécessitent un abonnement actif.", "paymentRequiredBodyMember": "L'abonnement du propriétaire a expiré. Les coffres d'équipe nécessitent un abonnement actif.", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 2d4e2a0b..6c79f5e6 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -73,10 +73,8 @@ "noConnectionsSelected": "Не выбрано ни одного подключения", "serialPortConfigNotFound": "Конфигурация последовательного порта не найдена", "connectionConfigNotFound": "Конфигурация подключения не найдена", - "failedToFetchAuditLogs": "Не удалось получить журнал аудита: {{status}}", "failedToExportAuditLogs": "Не удалось экспортировать журнал аудита: {{status}}", - "serverUnreachableTimeout": "Сервер недоступен (тайм-аут) — проверьте подключение к интернету и адрес сервера", "networkError": "Ошибка сети — {{message}}", "emailAlreadyRegistered": "Этот email уже зарегистрирован", @@ -99,7 +97,6 @@ "emailInUse": "Этот email уже используется", "incorrectPassword": "Неверный пароль", "emailUpdateFailed": "Не удалось обновить email: {{status}}", - "userNotFoundVoltiusAccount": "Пользователь не найден — у него должна быть учётная запись Voltius", "userNotFound": "Пользователь не найден", "cannotAddYourself": "Нельзя добавить самого себя", @@ -112,6 +109,7 @@ "noPermissionInviteMembers": "У вас нет прав на приглашение участников", "failedToCreateTeam": "Не удалось создать команду: {{status}}", "failedToListMembers": "Не удалось получить список участников: {{status}}", + "failedToListVaultKeyHolders": "Не удалось получить список владельцев ключа хранилища: {{status}}", "failedToAddMember": "Не удалось добавить участника: {{status}}", "failedToRemoveMember": "Не удалось удалить участника: {{status}}", "failedToAssignRole": "Не удалось назначить роль: {{status}}", @@ -124,7 +122,6 @@ "failedToRevokeInvitation": "Не удалось отозвать приглашение: {{status}}", "failedToAcceptInvitation": "Не удалось принять приглашение: {{status}}", "failedToDeclineInvitation": "Не удалось отклонить приглашение: {{status}}", - "noPermissionTeamVaultOp": "У вас нет прав на эту операцию с командным хранилищем", "teamVaultRequiresSubscription": "Для командного хранилища требуется активная подписка Teams или Business", "failedToListTeamObjects": "Не удалось получить список объектов команды: {{status}}", @@ -135,16 +132,13 @@ "failedToDeleteTeamObjectPref": "Не удалось удалить настройку объекта команды: {{status}}", "failedToListTeamSecrets": "Не удалось получить список секретов команды: {{status}}", "failedToSaveTeamSecret": "Не удалось сохранить секрет команды: {{status}}", - "keyFetchFailed": "Не удалось получить ключ: {{error}}", "failedToUploadVaultKeys": "Не удалось загрузить ключи хранилища: {{status}}", "failedToDistributeVaultKey": "Не удалось распространить ключ хранилища: {{status}}", "failedToSaveTeamData": "Не удалось сохранить данные команды: {{status}}", - "failedToCreateSession": "Не удалось создать сессию: {{status}}", "failedToCreateInviteLinkSession": "Не удалось создать сессию по ссылке-приглашению: {{status}}", "failedToGetSessionKey": "Не удалось получить ключ сессии: {{status}}", - "invalidJson": "Некорректный JSON", "notVoltiusExportBundle": "Это не пакет экспорта Voltius (ожидается { version: 1, ... })", "invalidEncryptedFile": "Некорректный зашифрованный файл", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index 882691d1..5f5d00f9 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -61,6 +61,8 @@ "offlineBody": "Командные хранилища требуют подключения к интернету. Личное хранилище доступно офлайн.", "forbiddenTitle": "Доступ отозван", "forbiddenBody": "У вас больше нет доступа к этому командному хранилищу. Обратитесь к владельцу команды.", + "waitingForAccessTitle": "Ожидание доступа", + "waitingForAccessBody": "Владелец командного хранилища должен предоставить вам доступ. Это произойдёт автоматически, когда владелец в следующий раз будет в сети.", "paymentRequiredTitle": "Командное хранилище недоступно", "paymentRequiredBodyOwner": "Срок действия вашей подписки истёк. Для командных хранилищ требуется активная подписка.", "paymentRequiredBodyMember": "Срок действия подписки владельца истёк. Для командных хранилищ требуется активная подписка.", diff --git a/src/services/sync.ts b/src/services/sync.ts index 7ef7899d..ad2924d5 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -817,23 +817,18 @@ async function handleRealtimeEvent(eventData: string, myDeviceId: string): Promi fetchTeamData(teamId, { background: true }).catch(() => {}); } else if (eventData.startsWith("team_members:")) { const teamId = eventData.slice("team_members:".length); - const prevMemberIds = new Set( - (useTeamStore.getState().membersByTeam[teamId] ?? []).map((m) => m.user_id), - ); await Promise.all([ useTeamStore.getState().loadTeams(), useTeamStore.getState().loadMembers(teamId), useTeamStore.getState().loadRoles(teamId), ]); - const newMembers = (useTeamStore.getState().membersByTeam[teamId] ?? []).filter( - (m) => !prevMemberIds.has(m.user_id) && m.public_key, - ); - if (newMembers.length > 0) { - const { distributeKeyToNewMember } = await import("@/services/teamVaultSync"); - await Promise.allSettled( - newMembers.map((m) => distributeKeyToNewMember(teamId, m.user_id, m.public_key)), - ); - } + // Distribute the vault key to any member who lacks one. Reconciliation + // against the server's key-holder list (rather than a local membership + // diff) also covers the adder-is-only-key-holder case, where the new + // member is already in membersByTeam by the time this event fires so the + // old diff saw zero newcomers and skipped distribution (issue #41). + const { reconcileTeamVaultKeys } = await import("@/services/teamVaultSync"); + await reconcileTeamVaultKeys(teamId).catch(() => {}); useTeamStore.getState().loadPendingInvitations(teamId).catch(() => {}); } else if (eventData.startsWith("pending_invitations_changed:")) { useTeamStore.getState().loadMyPendingInvitations().catch(() => {}); diff --git a/src/services/teamDataManager.ts b/src/services/teamDataManager.ts index 88965348..e4b6d42e 100644 --- a/src/services/teamDataManager.ts +++ b/src/services/teamDataManager.ts @@ -13,7 +13,7 @@ import { useKeyStore } from "@/stores/keyStore"; import { useFolderStore } from "@/stores/folderStore"; import { useSnippetStore } from "@/stores/snippetStore"; import { useSnippetFolderStore } from "@/stores/snippetFolderStore"; -import { fetchTeamData, clearTeamKeyCache } from "@/services/teamVaultSync"; +import { fetchTeamData, clearTeamKeyCache, reconcileTeamVaultKeys } from "@/services/teamVaultSync"; // Statuses that warrant a retry (transient — key not yet distributed) const TRANSIENT_STATUSES = new Set(["not_found", "error"]); @@ -25,7 +25,15 @@ const TRANSIENT_STATUSES = new Set(["not_found", "error"]); */ export async function onTeamLogin(): Promise { const teamIds = useTeamStore.getState().teams.map((t) => t.id); - await Promise.allSettled(teamIds.map((teamId) => fetchTeamData(teamId))); + await Promise.allSettled( + teamIds.map(async (teamId) => { + await fetchTeamData(teamId); + // A key-holder redistributes to any member who joined while it was + // offline — self-heals the async invite-acceptance lockout (issue #41). + // No-op for non-holders (they can't unwrap the key to redistribute). + await reconcileTeamVaultKeys(teamId); + }), + ); } /** diff --git a/src/services/teamService.transport.test.ts b/src/services/teamService.transport.test.ts index c2abd761..d70e591b 100644 --- a/src/services/teamService.transport.test.ts +++ b/src/services/teamService.transport.test.ts @@ -12,7 +12,7 @@ vi.mock("@/stores/subscriptionStore", () => ({ useSubscriptionStore: { getState: () => ({ load: h.load }) }, })); -import { listMembers, createTeam } from "./teamService"; +import { listMembers, createTeam, getVaultKeyHolders } from "./teamService"; // base64url JWT with a controllable exp (seconds since epoch) function jwt(expOffsetSec: number, sub = "user-1"): string { @@ -133,3 +133,12 @@ test("createTeam POST is re-shaped correctly after a valid-jwt path", async () = expect(init.method).toBe("POST"); expect(JSON.parse(init.body)).toEqual({ name: "Ops" }); }); + +test("getVaultKeyHolders GETs the holders route and returns the id list", async () => { + keychain({ jwt: jwt(3600), server_url: "https://s" }); + h.appFetch.mockResolvedValue(okJson(["u1", "u2"])); + const holders = await getVaultKeyHolders("t1"); + expect(holders).toEqual(["u1", "u2"]); + const [url] = h.appFetch.mock.calls[0]; + expect(url).toBe("https://s/v1/teams/t1/vault-key/holders"); +}); diff --git a/src/services/teamService.ts b/src/services/teamService.ts index 8fb817e6..7b353b6d 100644 --- a/src/services/teamService.ts +++ b/src/services/teamService.ts @@ -122,6 +122,19 @@ export async function listMembers(teamId: string): Promise { return res.json(); } +/** + * User ids that already hold a wrapped copy of the team vault key. A key-holder + * uses this to reconcile distribution: members present in `listMembers` but + * absent here are missing their key (issue #41). + */ +export async function getVaultKeyHolders(teamId: string): Promise { + const serverUrl = await getServerUrl(); + if (!serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + const res = await fetchAuth(`${serverUrl}/v1/teams/${teamId}/vault-key/holders`); + if (!res.ok) throw new Error(i18n.t("common.error.failedToListVaultKeyHolders", { status: res.status })); + return res.json(); +} + export async function addMember( teamId: string, email: string, diff --git a/src/services/teamVaultSync.reconcile.test.ts b/src/services/teamVaultSync.reconcile.test.ts new file mode 100644 index 00000000..80a31a0a --- /dev/null +++ b/src/services/teamVaultSync.reconcile.test.ts @@ -0,0 +1,113 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +// Reconciliation (issue #41): a key-holder detects team members who have joined +// but were never given a wrapped vault key, and distributes to exactly those — +// not to members who already hold one (avoiding redundant PUTs / SSE churn). + +const h = vi.hoisted(() => ({ + invoke: vi.fn(), + appFetch: vi.fn(), + listMembers: vi.fn(), + getMyUserId: vi.fn(), + getVaultKeyHolders: vi.fn(), + wrap: vi.fn(), + unwrap: vi.fn(), + keypair: vi.fn(), +})); +vi.mock("@tauri-apps/api/core", () => ({ invoke: h.invoke })); +vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@/services/teamService", () => ({ + listMembers: h.listMembers, + getMyUserId: h.getMyUserId, + getVaultKeyHolders: h.getVaultKeyHolders, +})); +vi.mock("@/services/multiplayerService", () => ({ + wrapSessionKeyForUser: h.wrap, + unwrapSessionKey: h.unwrap, + getMyX25519Keypair: h.keypair, +})); + +import { reconcileTeamVaultKeys, clearTeamKeyCache } from "./teamVaultSync"; + +function futureJwt(): string { + const exp = Math.floor(Date.now() / 1000) + 3600; + const b64 = btoa(JSON.stringify({ exp })).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `h.${b64}.s`; +} +const res = (status: number, body: unknown = {}) => + ({ status, ok: status >= 200 && status < 300, json: async () => body, headers: { get: () => null } }); + +const MEMBERS = [ + { user_id: "me", public_key: "MYPUB" }, + { user_id: "u_has", public_key: "pk_has" }, // already holds a key + { user_id: "u_missing", public_key: "pk_missing" }, // joined, no key yet + { user_id: "u_nopub", public_key: "" }, // no public key → cannot wrap +]; + +beforeEach(() => { + Object.values(h).forEach((m) => m.mockReset()); + clearTeamKeyCache(); + h.invoke.mockImplementation(async (cmd: string, args: { key: string }) => + cmd === "keychain_get" ? (args.key === "server_url" ? "https://s" : futureJwt()) : null, + ); + h.keypair.mockResolvedValue({ privateKey: "PRIV", publicKey: "MYPUB" }); + h.getMyUserId.mockResolvedValue("me"); + h.listMembers.mockResolvedValue(MEMBERS); + h.unwrap.mockResolvedValue(new Uint8Array(32)); + h.wrap.mockImplementation(async (_key: Uint8Array, pub: string) => `wrapped-for-${pub}`); +}); + +test("distributes only to members present in team_members but absent from holders", async () => { + h.getVaultKeyHolders.mockResolvedValue(["me", "u_has"]); + const puts: Array<{ user_id: string; wrapped_key: string }> = []; + h.appFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith("/vault-key") && (!init || init.method === "GET")) { + return res(200, { wrapped_key: "wk", wrapped_by_user_id: "me" }); + } + if (url.endsWith("/vault-key") && init?.method === "PUT") { + const body = JSON.parse(init.body as string) as { keys: typeof puts }; + puts.push(...body.keys); + return res(200); + } + throw new Error(`unexpected ${url}`); + }); + + await reconcileTeamVaultKeys("t1"); + + // Exactly one member (u_missing) should receive a key. + expect(puts.map((k) => k.user_id)).toEqual(["u_missing"]); + expect(puts).toContainEqual({ user_id: "u_missing", wrapped_key: "wrapped-for-pk_missing" }); +}); + +test("no-op when every member already holds a key (no PUT / no churn)", async () => { + h.getVaultKeyHolders.mockResolvedValue(["me", "u_has", "u_missing"]); + let putCount = 0; + h.appFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith("/vault-key") && (!init || init.method === "GET")) { + return res(200, { wrapped_key: "wk", wrapped_by_user_id: "me" }); + } + if (url.endsWith("/vault-key") && init?.method === "PUT") { + putCount++; + return res(200); + } + throw new Error(`unexpected ${url}`); + }); + + await reconcileTeamVaultKeys("t1"); + + expect(putCount).toBe(0); +}); + +test("does nothing when the client does not hold the key (not a key-holder)", async () => { + h.getVaultKeyHolders.mockResolvedValue([]); + h.appFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith("/vault-key") && (!init || init.method === "GET")) return res(404); + throw new Error(`unexpected ${url}`); + }); + + await reconcileTeamVaultKeys("t1"); + + // Never even queries holders when we can't unwrap the key ourselves. + expect(h.getVaultKeyHolders).not.toHaveBeenCalled(); +}); diff --git a/src/services/teamVaultSync.ts b/src/services/teamVaultSync.ts index 1d687c63..65dc6680 100644 --- a/src/services/teamVaultSync.ts +++ b/src/services/teamVaultSync.ts @@ -252,6 +252,53 @@ export async function distributeKeyToNewMember( if (!res.ok) throw new Error(i18n.t("common.error.failedToDistributeVaultKey", { status: res.status })); } +/** + * Reconcile vault-key distribution for a team (issue #41). + * + * Distribution is otherwise purely event-driven (team creation + the live + * `team_members` SSE diff), so a member who joins while the only key-holder is + * offline — or the very first invitee, whom the adder's own event never counts + * as "new" — is left with no key and silently locked out. This closes that gap: + * whenever a key-holder loads a team (login) or sees a membership change, it + * compares `team_members` against the server's key-holder list and wraps the key + * for anyone missing it. + * + * Safe to call on any team: if this client can't unwrap the key itself it isn't + * a key-holder and returns immediately. Only genuinely keyless members trigger a + * PUT, so repeated calls don't churn or re-notify existing holders. + */ +export async function reconcileTeamVaultKeys(teamId: string): Promise { + // Only a key-holder can distribute. Bail before touching the network for + // anything else if we can't obtain the raw key ourselves. + try { + await getTeamVaultKey(teamId); + } catch { + return; + } + + let members: TeamMember[]; + let holders: Set; + let myUserId: string | null; + try { + [members, holders, myUserId] = await Promise.all([ + teamService.listMembers(teamId), + teamService.getVaultKeyHolders(teamId).then((ids) => new Set(ids)), + teamService.getMyUserId(), + ]); + } catch { + return; + } + + const missing = members.filter( + (m) => m.public_key && m.user_id !== myUserId && !holders.has(m.user_id), + ); + if (missing.length === 0) return; + + await Promise.allSettled( + missing.map((m) => distributeKeyToNewMember(teamId, m.user_id, m.public_key)), + ); +} + // ─── Data fetch / save ──────────────────────────────────────────────────────── /**