Skip to content
Merged
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
80 changes: 22 additions & 58 deletions ghostkey-web/src/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,42 +395,6 @@ export function Dashboard({ onNavigate }: Props) {
<VaultClosedCard
meta={meta}
multiHeir={groupVaults.length > 1}
onDismiss={() => {
// Final close-out for this heir's share: drop the
// local meta so the dashboard stops treating this
// terminal vault as active. The server-side row
// stays — claim history is the owner's record. They
// can also "Remove heir" before dismissing if they
// want it gone server-side too.
removeVaultMeta(meta.id);
const remaining = groupVaults.filter(
(v) => v.id !== meta.id,
);
if (remaining.length === 0) {
// Same rule as removing an heir: land on a sibling in
// another group if there is one, otherwise say the
// vault is closed. Never the landing page.
const elsewhere = getAllVaultMetas();
if (elsewhere.length === 0) {
setEmptied("closed");
return;
}
setActiveVaultId(elsewhere[0].id);
if (typeof window !== "undefined") {
window.location.reload();
}
return;
}
// Multi-heir: the other heirs' vaults are still
// live and still need check-ins. Switch to the next
// one and reload so the dashboard re-derives against
// it rather than bouncing the owner to the landing
// page.
setActiveVaultId(remaining[0].id);
if (typeof window !== "undefined") {
window.location.reload();
}
}}
/>
) : isClaiming ? (
<ClaimInProgressCard
Expand Down Expand Up @@ -1559,11 +1523,9 @@ function AwaitingFundingCard({ meta }: { meta: VaultMeta }) {
function VaultClosedCard({
meta,
multiHeir,
onDismiss,
}: {
meta: VaultMeta;
multiHeir: boolean;
onDismiss: () => void;
}) {
return (
<section className="card relative overflow-hidden p-5 text-center md:p-8">
Expand All @@ -1574,19 +1536,20 @@ function VaultClosedCard({
>
</div>
{/* A claim closes one heir's share, never the vault. The
heading always carries the heir's name so this sentence can
never be read as a statement about the owner's whole vault
(which is what "This vault's work is done" did). There is no
dismiss button: the share stays visible, marked claimed, and
only leaves when the owner removes that heir. */}
<h2 className="mt-6 font-serif text-2xl">
{multiHeir
? `${meta.heir.name || "Your heir"}'s share claimed`
: "Vault closed"}
{`${meta.heir.name || "Your heir"}'s share is claimed`}
</h2>
<p className="mt-2 max-w-md text-sm text-muted">
{multiHeir
? `${meta.heir.name || "Your heir"} claimed their share. This part is done. Your other heirs' vaults are still active, so keep checking in for them.`
: `${meta.heir.name || "Your heir"} claimed the funds. Check-ins are no longer needed. This vault's work is done.`}
? `${meta.heir.name || "Your heir"} claimed their share. Your other shares are unaffected.`
: `${meta.heir.name || "Your heir"} claimed their share. Nothing more to do for it. You can add another heir any time.`}
</p>
<Button onClick={onDismiss} className="mt-6">
Done
</Button>
</div>
</section>
);
Expand Down Expand Up @@ -2463,7 +2426,7 @@ function humanAgo(then: Date, now: Date): string {
/** Why the dashboard has nothing to show. `null` is a device that never
* had a vault; the rest are owners who just closed one out and need to
* be told that, not greeted like a stranger. */
export type EmptyReason = null | "removed" | "closed" | "gone";
export type EmptyReason = null | "removed" | "gone";

function EmptyState({
onNavigate,
Expand All @@ -2472,17 +2435,17 @@ function EmptyState({
onNavigate: (r: Route) => void;
reason?: EmptyReason;
}) {
// Each heir is its own vault, so "add an heir" and "set up a vault" are
// the same act once nothing is left on the device. Lead with the owner's
// word for it.
// "Add an heir" and "set up a vault" are two different acts: the first
// adds a share to the vault you are already signed into, the second
// starts a separate vault on a separate email. This screen can only
// offer the second one honestly, because the owner's email and key live
// on the vault rows themselves — removing the last heir deletes the last
// row and the account with it, so there is nothing here to add a share
// to. Restoring "Add an heir" here needs the server to keep the row.
const copy = {
removed: {
title: "That was your last heir",
body: "This vault has nobody to inherit it now. Your Bitcoin is untouched and still yours — add an heir whenever you're ready.",
},
closed: {
title: "Vault closed",
body: "That heir's share has been claimed and this device is clear. Your other Bitcoin is unaffected. You can add another heir any time.",
body: "Your vault has no heirs right now. Your Bitcoin is untouched and still yours. Set up a vault again whenever you're ready.",
},
gone: {
title: "This vault is no longer on the server",
Expand All @@ -2504,9 +2467,10 @@ function EmptyState({
: "Set one up in a few minutes, or sign in with your email and password if you already have one."}
</p>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
<Button onClick={() => onNavigate("setup")}>
{shown && reason !== "gone" ? "Add an heir" : "Set up a vault"}
</Button>
{/* One route, one label. This button has always gone to the
full new-email setup flow, so labelling it "Add an heir"
told the owner it would do something it never did. */}
<Button onClick={() => onNavigate("setup")}>Set up a vault</Button>
{/* Sign in only where it can help. An owner who just closed out
their last heir is already signed in on this device, so
offering it there is noise. A vault that vanished from the
Expand Down
21 changes: 16 additions & 5 deletions ghostkey-web/src/SignInPortal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -483,8 +483,18 @@ export function SignInPortal({
let firstId: string | null = null;
let firstLiveId: string | null = null;

for (let i = 0; i < recoveryBundles.length; i++) {
const { vault: v, sealed_blobs: blobs } = recoveryBundles[i];
// Oldest share first. The owner asked to land on the heir they set up
// first, and "first" has to mean creation order, not whatever order
// the server happened to return the bundles in. This also fixes the
// order the metas are written in, which is the order the rest of the
// app reads them back.
const ordered = [...recoveryBundles].sort((a, b) => {
const byDate = a.vault.created_at.localeCompare(b.vault.created_at);
return byDate !== 0 ? byDate : a.vault.id.localeCompare(b.vault.id);
});

for (let i = 0; i < ordered.length; i++) {
const { vault: v, sealed_blobs: blobs } = ordered[i];
setPhase({ kind: "unsealing", vaultId: v.id, progress: 0 });

try {
Expand All @@ -507,7 +517,7 @@ export function SignInPortal({
setPhase({
kind: "unsealing",
vaultId: v.id,
progress: Math.round(((i + p) / recoveryBundles.length) * 100),
progress: Math.round(((i + p) / ordered.length) * 100),
}),
});

Expand Down Expand Up @@ -545,9 +555,10 @@ export function SignInPortal({
groupId,
});
if (!firstId) firstId = v.id;
// Prefer a vault the owner can still act on. Landing on a closed
// one just because it sorted first reads as "my heir claimed my
// Prefer a share the owner can still act on. Landing on a claimed
// one just because it came first reads as "my heir claimed my
// Bitcoin" to someone who came here to manage a different heir.
// With the sort above this is now the oldest share still active.
if (!firstLiveId && v.status !== "claimed") firstLiveId = v.id;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Expand Down
77 changes: 77 additions & 0 deletions ghostkey-web/src/vaultStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getAllVaultMetas,
getTrustedVaultId,
getVaultMeta,
getVaultsByGroup,
removeVaultMeta,
setActiveVaultId,
getVaultOwnerToken,
Expand Down Expand Up @@ -315,3 +316,79 @@ describe("device keeps knowing you after the active vault goes away", () => {
expect(getTrustedVaultId()).toBeNull();
});
});

describe("shares come back oldest first", () => {
beforeEach(() => {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: globalThis,
});
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: memoryStorage(),
});
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: memoryStorage(),
});
});

function saveShare(id: string, createdAt: string) {
saveVaultMeta({
id,
label: id,
owner: { address: "owner@example.com" },
heir: { name: id, email: "", address: "" },
createdAt,
ownerToken: `${id}-token`,
});
}

/** "The first heir I set up" has to survive the trip through storage.
* Object key order is the order things were written, which is not the
* order the owner created them in. */
it("orders by creation date, not by the order they were stored", () => {
saveShare("fola", "2026-08-02T00:00:00Z");
saveShare("ara", "2026-08-01T00:00:00Z");
saveShare("tunde", "2026-08-03T00:00:00Z");

expect(getAllVaultMetas().map((v) => v.id)).toEqual([
"ara",
"fola",
"tunde",
]);
});

it("agrees with getVaultsByGroup on what comes first", () => {
saveVaultMeta({
id: "second",
label: "second",
owner: { address: "owner@example.com" },
heir: { name: "second", email: "", address: "" },
createdAt: "2026-08-05T00:00:00Z",
ownerToken: "second-token",
groupId: "g1",
});
saveVaultMeta({
id: "first",
label: "first",
owner: { address: "owner@example.com" },
heir: { name: "first", email: "", address: "" },
createdAt: "2026-08-04T00:00:00Z",
ownerToken: "first-token",
groupId: "g1",
});

expect(getAllVaultMetas()[0].id).toBe("first");
expect(getVaultsByGroup("g1")[0].id).toBe("first");
});

/** Same timestamp is possible when several shares are created in one
* setup run. Ties break on id so the landing share never flips. */
it("breaks ties on id so the order is stable", () => {
saveShare("bbb", "2026-08-01T00:00:00Z");
saveShare("aaa", "2026-08-01T00:00:00Z");

expect(getAllVaultMetas().map((v) => v.id)).toEqual(["aaa", "bbb"]);
});
});
10 changes: 9 additions & 1 deletion ghostkey-web/src/vaultStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,8 +162,16 @@ export function getVaultOwnerToken(id: string): string | null {
return readLiveTokens()[id] ?? readAll()[id]?.ownerToken ?? null;
}

/** Every vault meta on this device, oldest first. The order matters:
* callers use "the first one" to mean the share the owner set up
* first, and object key order is an accident of how storage was
* written, not creation order. `getVaultsByGroup` already sorts this
* way; these two must not disagree. */
export function getAllVaultMetas(): VaultMeta[] {
return Object.values(readAll());
return Object.values(readAll()).sort((a, b) => {
const byDate = a.createdAt.localeCompare(b.createdAt);
return byDate !== 0 ? byDate : a.id.localeCompare(b.id);
});
}

export function hasLockedVaultCredential(id: string | null): boolean {
Expand Down