Skip to content
3 changes: 2 additions & 1 deletion src/components/layout/SidebarAccountButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { DropdownMenuItem } from "@/components/shared/DropdownMenuItem";
import { useCopyHandle } from "@/hooks/useCopyHandle";
import { useNotificationStore } from "@/stores/notificationStore";
import { useSecurityStore } from "@/stores/securityStore";
import { useEffectiveLockSettings } from "@/hooks/useEffectiveLockSettings";
import { canLockApp } from "@/utils/accountMode";
import { instanceLabel } from "@/utils/serverInstance";
import { IMMEDIATELY, sessionTimeoutLabel, sessionTimeoutValue } from "@/utils/sessionTimeout";
Expand All @@ -40,7 +41,7 @@ export function SidebarAccountButton() {
const [accountServerUrl, setAccountServerUrl] = useState<string | null>(null);
const [pendingSwitch, setPendingSwitch] = useState<SavedAccount | null>(null);
const { copied: handleCopied, copy: copyHandle } = useCopyHandle(accountHandle);
const sessionTimeoutMinutes = useSecurityStore((s) => s.sessionTimeoutMinutes);
const { sessionTimeoutMinutes } = useEffectiveLockSettings();
const systemAuthUnlock = useSecurityStore((s) => s.systemAuthUnlock);

const refreshAccountInfo = async (): Promise<ActiveAccount> => {
Expand Down
25 changes: 23 additions & 2 deletions src/components/layout/SplashScreen.appLock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const h = vi.hoisted(() => ({
autoLogin: vi.fn(async () => "ok"),
systemAuthUnlock: false,
available: true,
exists: true,
serverMode: false,
authProps: null as null | { isLocked: boolean; systemAuth?: boolean },
}));

Expand All @@ -18,13 +20,13 @@ vi.mock("@/services/account", () => ({
autoLogin: h.autoLogin,
getAppLock: vi.fn(async () => h.lock),
setAppLock: vi.fn(async () => {}),
isServerMode: vi.fn(async () => false),
isServerMode: vi.fn(async () => h.serverMode),
}));
vi.mock("@/services/appLock", () => ({ systemAuthAvailable: vi.fn(async () => h.available) }));
vi.mock("@/stores/securityStore", () => ({
useSecurityStore: { getState: () => ({ systemAuthUnlock: h.systemAuthUnlock }) },
}));
vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: true })) }));
vi.mock("@/services/vault", () => ({ getVaultStatus: vi.fn(async () => ({ exists: h.exists })) }));
vi.mock("./AuthPage", () => ({
default: (p: { isLocked: boolean; systemAuth?: boolean }) => { h.authProps = p; return <div>auth-page</div>; },
}));
Expand All @@ -36,6 +38,8 @@ beforeEach(() => {
vi.useFakeTimers();
h.lock = null;
h.systemAuthUnlock = false;
h.exists = true;
h.serverMode = false;
h.authProps = null;
h.autoLogin.mockClear();
});
Expand Down Expand Up @@ -74,3 +78,20 @@ test("a screen lock that survived a restart is up before the shell shows", async
expect(h.autoLogin).toHaveBeenCalled();
expect(lockedAtReady).toBe("screen");
});

test("a vault-locked cloud account with no vault file yet still gets the unlock page", async () => {
h.lock = "vault";
h.exists = false;
h.serverMode = true;
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: true });
});

test("a vault lock with no vault file and no cloud account is a first launch", async () => {
h.lock = "vault";
h.exists = false;
render(<SplashScreen onReady={() => {}} />);
await advance(2000);
expect(h.authProps).toMatchObject({ isLocked: false });
});
7 changes: 4 additions & 3 deletions src/components/layout/SplashScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ export default function SplashScreen({ onReady }: Props) {
if ((await getAppLock()) === "vault") {
setSystemAuth(useSecurityStore.getState().systemAuthUnlock && (await systemAuthAvailable()));
try {
const { exists } = await getVaultStatus();
setStep("vault", "done", exists ? t("layout.splash.vaultLocked") : t("layout.splash.firstLaunch"));
setPhase(exists ? "auth-locked" : "auth-first-launch");
// A cloud account has nothing on disk until its first secret; login() re-derives from the server.
const locked = (await getVaultStatus()).exists || (await isServerMode());
setStep("vault", "done", locked ? t("layout.splash.vaultLocked") : t("layout.splash.firstLaunch"));
setPhase(locked ? "auth-locked" : "auth-first-launch");
} catch {
setStep("vault", "error", t("layout.splash.vaultCheckFailed"));
setPhase("auth-first-launch");
Expand Down
6 changes: 3 additions & 3 deletions src/components/layout/VaultHeader.menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ beforeEach(() => {
teams: [], membersByTeam: {}, rolesByTeam: {},
loadMembers: vi.fn(async () => {}),
});
useUIStore.setState({ activeNav: "hosts", homeView: true, membersRolesPending: false, vaultSharePending: false });
useUIStore.setState({ activeNav: "hosts", homeView: true, membersPanelPending: null, vaultSharePending: false });
useSubscriptionStore.setState({ accountMode: "server" });
});
afterEach(cleanup);
Expand Down Expand Up @@ -79,7 +79,7 @@ test("choosing Members navigates to the Members page without marking roles pendi
const state = useUIStore.getState();
expect(state.activeNav).toBe("members");
expect(state.homeView).toBe(false);
expect(state.membersRolesPending).toBe(false);
expect(state.membersPanelPending).toBeNull();
});

test("choosing Roles navigates to the Members page and marks the roles panel pending", () => {
Expand All @@ -96,7 +96,7 @@ test("choosing Roles navigates to the Members page and marks the roles panel pen
const state = useUIStore.getState();
expect(state.activeNav).toBe("members");
expect(state.homeView).toBe(false);
expect(state.membersRolesPending).toBe(true);
expect(state.membersPanelPending).toBe("roles");
});

// The rail's Share… action has no share sheet of its own — it switches to the
Expand Down
4 changes: 2 additions & 2 deletions src/components/layout/VaultHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function MembersStack({
hasTeam?: boolean;
}) {
const { t } = useTranslation();
const openMembersInvite = useUIStore((s) => s.openMembersInvite);
const openMembersPanel = useUIStore((s) => s.openMembersPanel);
const [invHovered, setInvHovered] = useState(false);
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
const open = openProp ?? uncontrolledOpen;
Expand Down Expand Up @@ -168,7 +168,7 @@ export function MembersStack({
glass
>
<div onMouseEnter={openPopover} onMouseLeave={closePopover}>
<VaultShareSheet vaultId={vaultId} variant="popover" onRequestFull={openMembersInvite} />
<VaultShareSheet vaultId={vaultId} variant="popover" onRequestFull={() => openMembersPanel("invite")} />
</div>
</PickerSurface>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/layout/VaultRailShareTarget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,5 +116,5 @@ test("right-clicking a second vault while a different one is active and choosing

expect(useVaultStore.getState().selectedVaultIds[0]).toBe("v2");
expect(useUIStore.getState().activeNav).toBe("members");
expect(useUIStore.getState().membersRolesPending).toBe(true);
expect(useUIStore.getState().membersPanelPending).toBe("roles");
});
4 changes: 2 additions & 2 deletions src/components/members/MembersPage.BulkActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ vi.mock("@/stores/uiStore", () => {
membersSortMode: "name-asc",
setMembersLayoutMode: vi.fn(),
setMembersSortMode: vi.fn(),
membersInvitePending: false,
clearMembersInvitePending: vi.fn(),
membersPanelPending: null,
clearMembersPanelPending: vi.fn(),
openSettings: vi.fn(),
openCloudAuth: vi.fn(),
};
Expand Down
4 changes: 2 additions & 2 deletions src/components/members/MembersPage.InviteToSession.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,8 @@ vi.mock("@/stores/uiStore", () => {
membersSortMode: "name-asc",
setMembersLayoutMode: vi.fn(),
setMembersSortMode: vi.fn(),
membersInvitePending: false,
clearMembersInvitePending: vi.fn(),
membersPanelPending: null,
clearMembersPanelPending: vi.fn(),
openSettings: vi.fn(),
openCloudAuth: vi.fn(),
};
Expand Down
18 changes: 8 additions & 10 deletions src/components/members/MembersPage.RolesPending.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const h = vi.hoisted(() => ({
loadMembers: vi.fn(),
loadRoles: vi.fn(),
loadPendingInvitations: vi.fn(),
clearMembersRolesPending: vi.fn(),
clearMembersPanelPending: vi.fn(),
}));

vi.mock("react-i18next", () => ({
Expand Down Expand Up @@ -107,18 +107,16 @@ vi.mock("@/stores/subscriptionStore", () => {
);
return { useSubscriptionStore };
});
// membersRolesPending starts true, as if the vault menu's Roles action had
// just run openMembersRoles() before this page mounted.
// membersPanelPending starts at "roles", as if the vault menu's Roles action had
// just run openMembersPanel("roles") before this page mounted.
vi.mock("@/stores/uiStore", () => {
const state = {
membersLayoutMode: "list",
membersSortMode: "name-asc",
setMembersLayoutMode: vi.fn(),
setMembersSortMode: vi.fn(),
membersInvitePending: false,
clearMembersInvitePending: vi.fn(),
membersRolesPending: true,
clearMembersRolesPending: h.clearMembersRolesPending,
membersPanelPending: "roles",
clearMembersPanelPending: h.clearMembersPanelPending,
openSettings: vi.fn(),
openCloudAuth: vi.fn(),
};
Expand Down Expand Up @@ -149,12 +147,12 @@ beforeEach(() => {
h.loadMembers.mockReset().mockResolvedValue(undefined);
h.loadRoles.mockReset().mockResolvedValue(undefined);
h.loadPendingInvitations.mockReset().mockResolvedValue(undefined);
h.clearMembersRolesPending.mockReset();
h.clearMembersPanelPending.mockReset();
});
afterEach(() => cleanup());

// The uncleared-flag case is the one that actually bites users: if the
// membersRolesPending effect fires without clearing the flag, the roles
// membersPanelPending effect fires without clearing the flag, the roles
// panel re-opens on every later visit to the Members page, overriding
// whatever the user had open.
test("a pending roles request opens the roles panel and clears the flag", async () => {
Expand All @@ -163,5 +161,5 @@ test("a pending roles request opens the roles panel and clears the flag", async

expect(await screen.findByTestId("team-roles-panel")).toBeTruthy();
expect(screen.getByTestId("team-roles-panel").textContent).toBe("t1");
expect(h.clearMembersRolesPending).toHaveBeenCalledTimes(1);
expect(h.clearMembersPanelPending).toHaveBeenCalledTimes(1);
});
2 changes: 1 addition & 1 deletion src/components/members/MembersPage.SelfCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ vi.mock("@/stores/uiStore", () => {
const state = {
membersLayoutMode: "list", membersSortMode: "name-asc",
setMembersLayoutMode: vi.fn(), setMembersSortMode: vi.fn(),
membersInvitePending: false, clearMembersInvitePending: vi.fn(),
membersPanelPending: null, clearMembersPanelPending: vi.fn(),
openSettings: vi.fn(), openCloudAuth: vi.fn(),
};
const useUIStore = Object.assign(
Expand Down
Loading
Loading