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
160 changes: 156 additions & 4 deletions apps/mobile/src/features/connection/LinkedEnvironmentsSection.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import { useAtomValue } from "@effect/atom-react";
import { offersLinkedEnvironments } from "@t3tools/client-runtime/link-request";
import {
isTailnetDevicesForbidden,
linkedDeviceRows,
} from "@t3tools/client-runtime/tailnet-devices";
import { peerLinkHealth } from "@t3tools/client-runtime/state/peerLinks";
import { useFocusEffect } from "@react-navigation/native";
import { AuthAccessReadScope, type EnvironmentId } from "@t3tools/contracts";
import {
AuthAccessReadScope,
type AuthMcpClientAccess,
type EnvironmentId,
type TailnetDevice,
type TailnetDeviceAccess,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { useCallback, useState } from "react";
import { AppState, View } from "react-native";
import { AppState, Pressable, View } from "react-native";

import { SymbolView } from "../../components/ui/AppSymbol";
import { AppText as Text } from "../../components/ui/AppText";
import * as Dialog from "../../components/ui/Dialog";
import { ListChoiceRow } from "../../components/ui/ListChoiceRow";
import { useEnvironmentServerConfig } from "../../state/entities";
import { peerLinkEnvironment } from "../../state/peerLinks";
import { useEnvironmentQuery } from "../../state/query";
import { useEnvironmentScope } from "../../state/session";
import { tailnetDevices } from "../../state/tailnetDevices";
import { useAtomCommand } from "../../state/use-atom-command";
import { ACCESS_CHOICES } from "../threads/thread-settings-options";
import { SettingsSection } from "../settings/components/SettingsSection";

export function LinkedEnvironmentsSection(props: {
Expand All @@ -23,6 +40,9 @@ export function LinkedEnvironmentsSection(props: {
const links = useEnvironmentQuery(
supported ? peerLinkEnvironment.list({ environmentId: props.environmentId, input: {} }) : null,
);
const devices = useEnvironmentQuery(
supported ? tailnetDevices.list({ environmentId: props.environmentId, input: {} }) : null,
);
const [nowMs, setNowMs] = useState(Date.now);
const hasLinks = supported && (links.data?.links.length ?? 0) > 0;
useFocusEffect(
Expand All @@ -40,12 +60,31 @@ export function LinkedEnvironmentsSection(props: {
};
}, [hasLinks]),
);
if (!supported || links.error || !links.data || links.data.links.length === 0) return null;
if (!supported || links.error || !links.data) return null;
const rows = linkedDeviceRows({
devices: devices.data?.devices ?? [],
links: links.data.links,
savedEnvironmentIds: new Set(),
});
if (rows.length === 0) return null;
// Older servers, and this one until it can link devices, fail the list; that is not an error.
const devicesUnavailable = devices.error !== null && !isTailnetDevicesForbidden(devices.failure);
const now = DateTime.makeUnsafe(nowMs);
return (
<View className="mt-5 gap-3">
<SettingsSection title={`Linked from ${props.environmentLabel}`}>
{links.data.links.map((link) => {
{rows.map(({ device, link }) => {
if (device !== null) {
return (
<DeviceRow
key={device.environmentId}
hostEnvironmentId={props.environmentId}
device={device}
restoreAccess={devices.data?.settings.defaultAccess ?? "approval-required"}
/>
);
}
if (link === null) return null;
const health = peerLinkHealth(link, now);
const status =
health.kind === "expired"
Expand Down Expand Up @@ -74,6 +113,119 @@ export function LinkedEnvironmentsSection(props: {
);
})}
</SettingsSection>
{devicesUnavailable ? (
<Text className="px-2 text-sm text-foreground-muted">
Your devices are not available on this server yet.
</Text>
) : null}
</View>
);
}

const DEVICE_STATUS: Record<TailnetDevice["status"], string> = {
online: "Online",
offline: "Offline",
revoked: "Revoked",
};

/** One of your devices, with its access on the host; Revoked is one of the choices. */
function DeviceRow(props: {
readonly hostEnvironmentId: EnvironmentId;
readonly device: TailnetDevice;
readonly restoreAccess: AuthMcpClientAccess;
}) {
const { device } = props;
const canSetAccess = useAtomValue(
tailnetDevices.setAccess.permissionAtom(props.hostEnvironmentId),
);
const setAccess = useAtomCommand(tailnetDevices.setAccess, { reportFailure: false });
const [expanded, setExpanded] = useState(false);
const [saving, setSaving] = useState(false);
const revoked = device.access === "revoked";
const accessLabel = revoked
? "Revoked"
: (ACCESS_CHOICES.find((choice) => choice.access === device.access)?.label ?? device.access);
const save = (access: TailnetDeviceAccess) => {
setSaving(true);
void setAccess({
environmentId: props.hostEnvironmentId,
input: { environmentId: device.environmentId, access },
}).then((result) => {
setSaving(false);
if (result._tag === "Failure") {
Dialog.alert(`Could not change ${device.label}'s access`, "Try again in a moment.");
} else {
setExpanded(false);
}
});
};
const choose = (access: TailnetDeviceAccess) => {
if (access === device.access) return;
if (access !== "revoked") return save(access);
Dialog.confirm({
title: `Revoke ${device.label}?`,
message: "It loses access on every one of your devices until someone restores it.",
confirmText: "Revoke",
destructive: true,
onConfirm: () => save("revoked"),
});
};
return (
<View>
<Pressable
accessibilityRole="button"
accessibilityLabel={`${device.label} access`}
accessibilityState={{ expanded, disabled: !canSetAccess }}
disabled={!canSetAccess}
className="flex-row items-center gap-3 p-4"
onPress={() => setExpanded((open) => !open)}
>
<View className="min-w-0 flex-1 gap-0.5">
<Text className="text-base font-t3-bold text-foreground">{device.label}</Text>
<Text
className={revoked ? "text-xs text-danger-foreground" : "text-xs text-foreground-muted"}
numberOfLines={1}
>
{[
device.outgoing === "paired" ? "Paired" : "Tailnet",
DEVICE_STATUS[device.status],
].join(" · ")}
</Text>
</View>
<Text className="text-sm text-foreground-muted">{accessLabel}</Text>
{canSetAccess ? (
<SymbolView
name={expanded ? "chevron.up" : "chevron.down"}
size={12}
tintColorClassName="accent-icon-muted"
/>
) : null}
</Pressable>
{expanded ? (
<>
{ACCESS_CHOICES.map((choice) => (
<ListChoiceRow
key={choice.access}
label={
revoked && choice.access === props.restoreAccess
? `Restore as ${choice.label}`
: choice.label
}
description={choice.description}
selected={device.access === choice.access}
disabled={saving}
onPress={() => choose(choice.access)}
/>
))}
<ListChoiceRow
label="Revoked"
description="No access on any of your devices until someone restores it."
selected={revoked}
disabled={saving}
onPress={() => choose("revoked")}
/>
</>
) : null}
</View>
);
}
15 changes: 1 addition & 14 deletions apps/mobile/src/features/threads/LinkRequestCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,8 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { ListChoiceRow as SettingsChoiceRow } from "../../components/ui/ListChoiceRow";
import { SettingsSection } from "../settings/components/SettingsSection";
import { Button } from "../../components/ui/Button";
import { RUNTIME_MODE_CHOICES } from "./thread-settings-options";
import { ACCESS_CHOICES } from "./thread-settings-options";

/** The same five levels as web's access picker: read-only, then the runtime modes. */
const ACCESS_CHOICES: ReadonlyArray<{
readonly access: AuthMcpClientAccess;
readonly label: string;
readonly description: string;
}> = [
{
access: "read-only",
label: "Read only",
description: "Read projects and threads. Cannot start, message or change anything.",
},
...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })),
];
const accessLabel = (access: AuthMcpClientAccess) =>
ACCESS_CHOICES.find((choice) => choice.access === access)?.label ?? access;

Expand Down
16 changes: 15 additions & 1 deletion apps/mobile/src/features/threads/thread-settings-options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RuntimeMode } from "@t3tools/contracts";
import type { AuthMcpClientAccess, RuntimeMode } from "@t3tools/contracts";

export const RUNTIME_MODE_CHOICES: ReadonlyArray<{
readonly mode: RuntimeMode;
Expand Down Expand Up @@ -45,3 +45,17 @@ export function compatibleRuntimeModeForChoices(
}

export { selectableProviderOptionChoices as selectableChoices } from "@t3tools/client-runtime/model-traits";

/** The same five levels as web's access picker: read-only, then the runtime modes. */
export const ACCESS_CHOICES: ReadonlyArray<{
readonly access: AuthMcpClientAccess;
readonly label: string;
readonly description: string;
}> = [
{
access: "read-only",
label: "Read only",
description: "Read projects and threads. Cannot start, message or change anything.",
},
...RUNTIME_MODE_CHOICES.map((choice) => ({ ...choice, access: choice.mode })),
];
5 changes: 5 additions & 0 deletions apps/mobile/src/state/tailnetDevices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createTailnetDeviceAtoms } from "@t3tools/client-runtime/state/tailnetDevices";

import { connectionAtomRuntime } from "../connection/runtime";

export const tailnetDevices = createTailnetDeviceAtoms(connectionAtomRuntime);
Loading
Loading