Skip to content

Commit 9e40bba

Browse files
authored
fix: tell both people where an ask to join stands (#643)
The asker's Waiting… compared peers by object, so the list's 5 s refresh dropped it to a disabled Ask to join; it now says when the request lapses and toasts when they're let in. The owner gets a desktop notification for an ask that arrives while Ace isn't focused, the dialog says when it lapses, and Approve or Deny confirms.
1 parent 2a50fb3 commit 9e40bba

4 files changed

Lines changed: 51 additions & 12 deletions

File tree

‎apps/app/src/team-asks.tsx‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useSyncExternalStore } from "react";
1+
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
22

33
import {
44
Button,
@@ -8,10 +8,15 @@ import {
88
DialogFooter,
99
DialogHeader,
1010
DialogTitle,
11+
toast,
1112
} from "@ace/ui";
1213

14+
import { desktop } from "./desktop";
15+
import { isFocused } from "./focus";
1316
import { host } from "./host";
1417

18+
const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
19+
1520
/**
1621
* Someone on the tailnet asked to join this host's team. Approving sends them the directory URL
1722
* and team secret; closing leaves the ask to expire.
@@ -21,6 +26,19 @@ export function TeamAsks() {
2126
const [dismissed, setDismissed] = useState<string[]>([]);
2227
const [busy, setBusy] = useState(false);
2328
const [error, setError] = useState("");
29+
// The dialog waits in a window the owner may not be looking at; the ask lapses unanswered.
30+
const told = useRef(new Set<string>());
31+
useEffect(() => {
32+
for (const ask of asks) {
33+
if (told.current.has(ask.id)) continue;
34+
told.current.add(ask.id);
35+
if (isFocused()) continue;
36+
desktop?.notify(
37+
`Let ${ask.github || ask.login} join your team?`,
38+
`${ask.login} on ${ask.host} asked. Open Ace to approve or deny.`,
39+
).catch(() => {});
40+
}
41+
}, [asks]);
2442
const ask = asks.find((value) => !dismissed.includes(value.id));
2543
if (!ask) return null;
2644
const who = ask.github || ask.login;
@@ -30,6 +48,11 @@ export function TeamAsks() {
3048
setError("");
3149
try {
3250
await host.request({ op: "team-answer", ask: ask!.id, approve });
51+
if (approve) {
52+
toast.success(`Let ${who} in`, {
53+
description: `${ask!.host} has your team's directory and joins it now.`,
54+
});
55+
} else toast(`Declined ${who}'s request`);
3356
} catch (error) {
3457
setError((error as Error).message);
3558
} finally {
@@ -46,7 +69,7 @@ export function TeamAsks() {
4669
{ask.login} on {ask.host}{" "}
4770
asked from Ace. Approving sends this host's directory URL and team secret to {ask.host}
4871
{" "}
49-
over your tailnet.
72+
over your tailnet. The request lapses at {CLOCK.format(ask.expires)}.
5073
</DialogDescription>
5174
</DialogHeader>
5275
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}

‎apps/app/src/team-setup.tsx‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ import {
1010
DialogHeader,
1111
DialogTitle,
1212
Input,
13+
toast,
1314
} from "@ace/ui";
1415
import { IconArrowUpRight, IconCheck, IconCopy, IconLoader, IconUsers } from "@ace/ui/icons";
15-
import type { TeamPeer } from "@ace/host/protocol";
16+
import { TEAM_ASK_EXPIRES, type TeamPeer } from "@ace/host/protocol";
1617

1718
import { host } from "./host";
1819
import { useDiagnostics } from "./host-settings";
@@ -429,18 +430,26 @@ function useTeamPeers(): TeamPeer[] {
429430
return peers;
430431
}
431432

433+
const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });
434+
432435
/** Ask a teammate's host to let this one in; their owner approves in their Ace. */
433436
function Nearby({ onJoined }: { onJoined: () => void }) {
434437
const peers = useTeamPeers();
435-
const [asking, setAsking] = useState<TeamPeer>();
438+
// By host: the peer list is read again every few seconds, which replaces its objects.
439+
const [asking, setAsking] = useState<{ host: string; who: string; lapses: number }>();
436440
const [error, setError] = useState("");
437441
if (!peers.length) return null;
438442

439443
async function ask(peer: TeamPeer) {
440-
setAsking(peer);
444+
const who = peer.github || peer.login;
445+
setAsking({ host: peer.host, who, lapses: Date.now() + TEAM_ASK_EXPIRES });
441446
setError("");
442447
try {
443448
await host.request({ op: "team-ask", host: peer.host });
449+
// Joining closes Team setup, so the asker may have looked away; say it outside it.
450+
toast.success(`${who} let you in`, {
451+
description: "You're in your team's directory now.",
452+
});
444453
onJoined();
445454
} catch (error) {
446455
setError((error as Error).message);
@@ -468,14 +477,18 @@ function Nearby({ onJoined }: { onJoined: () => void }) {
468477
disabled={!!asking}
469478
onClick={() => void ask(peer)}
470479
>
471-
{asking === peer && <IconLoader className="size-3.5 animate-spin" aria-hidden />}
472-
{asking === peer ? "Waiting…" : "Ask to join"}
480+
{asking?.host === peer.host && (
481+
<IconLoader className="size-3.5 animate-spin" aria-hidden />
482+
)}
483+
{asking?.host === peer.host ? "Waiting…" : "Ask to join"}
473484
</Button>
474485
</div>
475486
))}
476487
{asking && (
477488
<p className="text-xs text-muted-foreground">
478-
{asking.github || asking.login} approves it in their Ace. You join as soon as they do.
489+
Asked {asking.who}{" "}
490+
to approve it in their Ace. You join as soon as they do; the request lapses at{" "}
491+
{CLOCK.format(asking.lapses)} if nobody answers.
479492
</p>
480493
)}
481494
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}

‎apps/host/src/asks.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,9 @@
44
* WireGuard, so nobody sends them by hand. Tailscale names who is asking, and only this host's
55
* owner, from its local app, can approve.
66
*/
7-
import type { Invite, TeamAsk } from "./protocol";
7+
import { type Invite, TEAM_ASK_EXPIRES as EXPIRES, type TeamAsk } from "./protocol";
88
import { invite } from "./settings";
99

10-
/** Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. */
11-
const EXPIRES = 5 * 60_000;
12-
1310
type Waiting = {
1411
ask: TeamAsk;
1512
/** The asking peer's socket, so an ask ends with its connection. */

‎apps/host/src/protocol.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,12 @@ export type Member = {
197197

198198
export type TeamPeer = { host: string; login: string; github?: string; team: string };
199199

200+
/**
201+
* Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. The
202+
* asker's app names when it lapses from this.
203+
*/
204+
export const TEAM_ASK_EXPIRES = 5 * 60_000;
205+
200206
/** Someone on the tailnet asking this host's owner to let them into the team. */
201207
export type TeamAsk = { id: string; login: string; host: string; github?: string; expires: number };
202208

0 commit comments

Comments
 (0)