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
27 changes: 25 additions & 2 deletions apps/app/src/team-asks.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useSyncExternalStore } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";

import {
Button,
Expand All @@ -8,10 +8,15 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
toast,
} from "@ace/ui";

import { desktop } from "./desktop";
import { isFocused } from "./focus";
import { host } from "./host";

const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });

/**
* Someone on the tailnet asked to join this host's team. Approving sends them the directory URL
* and team secret; closing leaves the ask to expire.
Expand All @@ -21,6 +26,19 @@ export function TeamAsks() {
const [dismissed, setDismissed] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
// The dialog waits in a window the owner may not be looking at; the ask lapses unanswered.
const told = useRef(new Set<string>());
useEffect(() => {
for (const ask of asks) {
if (told.current.has(ask.id)) continue;
told.current.add(ask.id);
if (isFocused()) continue;
desktop?.notify(
`Let ${ask.github || ask.login} join your team?`,
`${ask.login} on ${ask.host} asked. Open Ace to approve or deny.`,
).catch(() => {});
}
}, [asks]);
const ask = asks.find((value) => !dismissed.includes(value.id));
if (!ask) return null;
const who = ask.github || ask.login;
Expand All @@ -30,6 +48,11 @@ export function TeamAsks() {
setError("");
try {
await host.request({ op: "team-answer", ask: ask!.id, approve });
if (approve) {
toast.success(`Let ${who} in`, {
description: `${ask!.host} has your team's directory and joins it now.`,
});
} else toast(`Declined ${who}'s request`);
} catch (error) {
setError((error as Error).message);
} finally {
Expand All @@ -46,7 +69,7 @@ export function TeamAsks() {
{ask.login} on {ask.host}{" "}
asked from Ace. Approving sends this host's directory URL and team secret to {ask.host}
{" "}
over your tailnet.
over your tailnet. The request lapses at {CLOCK.format(ask.expires)}.
</DialogDescription>
</DialogHeader>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
Expand Down
25 changes: 19 additions & 6 deletions apps/app/src/team-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ import {
DialogHeader,
DialogTitle,
Input,
toast,
} from "@ace/ui";
import { IconArrowUpRight, IconCheck, IconCopy, IconLoader, IconUsers } from "@ace/ui/icons";
import type { TeamPeer } from "@ace/host/protocol";
import { TEAM_ASK_EXPIRES, type TeamPeer } from "@ace/host/protocol";

import { host } from "./host";
import { useDiagnostics } from "./host-settings";
Expand Down Expand Up @@ -429,18 +430,26 @@ function useTeamPeers(): TeamPeer[] {
return peers;
}

const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });

/** Ask a teammate's host to let this one in; their owner approves in their Ace. */
function Nearby({ onJoined }: { onJoined: () => void }) {
const peers = useTeamPeers();
const [asking, setAsking] = useState<TeamPeer>();
// By host: the peer list is read again every few seconds, which replaces its objects.
const [asking, setAsking] = useState<{ host: string; who: string; lapses: number }>();
const [error, setError] = useState("");
if (!peers.length) return null;

async function ask(peer: TeamPeer) {
setAsking(peer);
const who = peer.github || peer.login;
setAsking({ host: peer.host, who, lapses: Date.now() + TEAM_ASK_EXPIRES });
setError("");
try {
await host.request({ op: "team-ask", host: peer.host });
// Joining closes Team setup, so the asker may have looked away; say it outside it.
toast.success(`${who} let you in`, {
description: "You're in your team's directory now.",
});
onJoined();
} catch (error) {
setError((error as Error).message);
Expand Down Expand Up @@ -468,14 +477,18 @@ function Nearby({ onJoined }: { onJoined: () => void }) {
disabled={!!asking}
onClick={() => void ask(peer)}
>
{asking === peer && <IconLoader className="size-3.5 animate-spin" aria-hidden />}
{asking === peer ? "Waiting…" : "Ask to join"}
{asking?.host === peer.host && (
<IconLoader className="size-3.5 animate-spin" aria-hidden />
)}
{asking?.host === peer.host ? "Waiting…" : "Ask to join"}
</Button>
</div>
))}
{asking && (
<p className="text-xs text-muted-foreground">
{asking.github || asking.login} approves it in their Ace. You join as soon as they do.
Asked {asking.who}{" "}
to approve it in their Ace. You join as soon as they do; the request lapses at{" "}
{CLOCK.format(asking.lapses)} if nobody answers.
</p>
)}
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
Expand Down
5 changes: 1 addition & 4 deletions apps/host/src/asks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,9 @@
* WireGuard, so nobody sends them by hand. Tailscale names who is asking, and only this host's
* owner, from its local app, can approve.
*/
import type { Invite, TeamAsk } from "./protocol";
import { type Invite, TEAM_ASK_EXPIRES as EXPIRES, type TeamAsk } from "./protocol";
import { invite } from "./settings";

/** Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. */
const EXPIRES = 5 * 60_000;

type Waiting = {
ask: TeamAsk;
/** The asking peer's socket, so an ask ends with its connection. */
Expand Down
6 changes: 6 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ export type Member = {

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

/**
* Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. The
* asker's app names when it lapses from this.
*/
export const TEAM_ASK_EXPIRES = 5 * 60_000;

/** Someone on the tailnet asking this host's owner to let them into the team. */
export type TeamAsk = { id: string; login: string; host: string; github?: string; expires: number };

Expand Down