Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/components/layout/MainPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 1 addition & 7 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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}}",
Expand All @@ -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}}",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
8 changes: 1 addition & 7 deletions src/i18n/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand All @@ -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",
Expand All @@ -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}}",
Expand All @@ -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}}",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/fr/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
8 changes: 1 addition & 7 deletions src/i18n/locales/ru/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,8 @@
"noConnectionsSelected": "Не выбрано ни одного подключения",
"serialPortConfigNotFound": "Конфигурация последовательного порта не найдена",
"connectionConfigNotFound": "Конфигурация подключения не найдена",

"failedToFetchAuditLogs": "Не удалось получить журнал аудита: {{status}}",
"failedToExportAuditLogs": "Не удалось экспортировать журнал аудита: {{status}}",

"serverUnreachableTimeout": "Сервер недоступен (тайм-аут) — проверьте подключение к интернету и адрес сервера",
"networkError": "Ошибка сети — {{message}}",
"emailAlreadyRegistered": "Этот email уже зарегистрирован",
Expand All @@ -99,7 +97,6 @@
"emailInUse": "Этот email уже используется",
"incorrectPassword": "Неверный пароль",
"emailUpdateFailed": "Не удалось обновить email: {{status}}",

"userNotFoundVoltiusAccount": "Пользователь не найден — у него должна быть учётная запись Voltius",
"userNotFound": "Пользователь не найден",
"cannotAddYourself": "Нельзя добавить самого себя",
Expand All @@ -112,6 +109,7 @@
"noPermissionInviteMembers": "У вас нет прав на приглашение участников",
"failedToCreateTeam": "Не удалось создать команду: {{status}}",
"failedToListMembers": "Не удалось получить список участников: {{status}}",
"failedToListVaultKeyHolders": "Не удалось получить список владельцев ключа хранилища: {{status}}",
"failedToAddMember": "Не удалось добавить участника: {{status}}",
"failedToRemoveMember": "Не удалось удалить участника: {{status}}",
"failedToAssignRole": "Не удалось назначить роль: {{status}}",
Expand All @@ -124,7 +122,6 @@
"failedToRevokeInvitation": "Не удалось отозвать приглашение: {{status}}",
"failedToAcceptInvitation": "Не удалось принять приглашение: {{status}}",
"failedToDeclineInvitation": "Не удалось отклонить приглашение: {{status}}",

"noPermissionTeamVaultOp": "У вас нет прав на эту операцию с командным хранилищем",
"teamVaultRequiresSubscription": "Для командного хранилища требуется активная подписка Teams или Business",
"failedToListTeamObjects": "Не удалось получить список объектов команды: {{status}}",
Expand All @@ -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": "Некорректный зашифрованный файл",
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/ru/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
"offlineBody": "Командные хранилища требуют подключения к интернету. Личное хранилище доступно офлайн.",
"forbiddenTitle": "Доступ отозван",
"forbiddenBody": "У вас больше нет доступа к этому командному хранилищу. Обратитесь к владельцу команды.",
"waitingForAccessTitle": "Ожидание доступа",
"waitingForAccessBody": "Владелец командного хранилища должен предоставить вам доступ. Это произойдёт автоматически, когда владелец в следующий раз будет в сети.",
"paymentRequiredTitle": "Командное хранилище недоступно",
"paymentRequiredBodyOwner": "Срок действия вашей подписки истёк. Для командных хранилищ требуется активная подписка.",
"paymentRequiredBodyMember": "Срок действия подписки владельца истёк. Для командных хранилищ требуется активная подписка.",
Expand Down
19 changes: 7 additions & 12 deletions src/services/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {});
Expand Down
12 changes: 10 additions & 2 deletions src/services/teamDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -25,7 +25,15 @@ const TRANSIENT_STATUSES = new Set(["not_found", "error"]);
*/
export async function onTeamLogin(): Promise<void> {
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);
}),
);
}

/**
Expand Down
11 changes: 10 additions & 1 deletion src/services/teamService.transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
});
13 changes: 13 additions & 0 deletions src/services/teamService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,19 @@ export async function listMembers(teamId: string): Promise<TeamMember[]> {
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<string[]> {
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,
Expand Down
Loading
Loading