total-recall-brain 3.25.0 → 3.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/frontend/dist/index.html
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
18
18
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
19
19
|
<title>Total Recall — Portable Memory</title>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
+
<script type="module" crossorigin src="/assets/index-DWCNvgnv.js"></script>
|
|
21
21
|
<link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
|
|
22
22
|
</head>
|
|
23
23
|
<body>
|
|
@@ -4,9 +4,10 @@ import { TailnetEnroll } from './TailnetEnroll';
|
|
|
4
4
|
|
|
5
5
|
vi.mock('../api/mesh', () => ({
|
|
6
6
|
fetchNodes: vi.fn(),
|
|
7
|
+
fetchEnrollmentStatus: vi.fn(),
|
|
7
8
|
mintPreAuthKey: vi.fn(),
|
|
8
9
|
}));
|
|
9
|
-
import { fetchNodes, mintPreAuthKey } from '../api/mesh';
|
|
10
|
+
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
|
|
10
11
|
|
|
11
12
|
const node = (over = {}) => ({
|
|
12
13
|
hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
|
|
@@ -15,6 +16,7 @@ const node = (over = {}) => ({
|
|
|
15
16
|
describe('TailnetEnroll', () => {
|
|
16
17
|
beforeEach(() => {
|
|
17
18
|
vi.mocked(fetchNodes).mockResolvedValue([node(), node({ hostname: 'macmini', ip: '100.64.0.2', self: true })] as never);
|
|
19
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValue({ login_server: 'https://control.example.org' } as never);
|
|
18
20
|
vi.mocked(mintPreAuthKey).mockResolvedValue({
|
|
19
21
|
success: true,
|
|
20
22
|
key: 'hskey-auth-TESTKEY',
|
|
@@ -26,17 +28,60 @@ describe('TailnetEnroll', () => {
|
|
|
26
28
|
} as never);
|
|
27
29
|
});
|
|
28
30
|
|
|
31
|
+
it('shows the control server and phone steps BEFORE any key is minted', async () => {
|
|
32
|
+
// The regression: instructions lived inside the `minted` branch behind a
|
|
33
|
+
// collapsed <details>, so step one -- the URL you must enter on the phone
|
|
34
|
+
// -- was invisible until a 15-minute countdown was already running.
|
|
35
|
+
render(<TailnetEnroll />);
|
|
36
|
+
expect(await screen.findByTestId('enroll-instructions')).toBeTruthy();
|
|
37
|
+
expect((await screen.findByTestId('enroll-login-server')).textContent).toContain(
|
|
38
|
+
'https://control.example.org',
|
|
39
|
+
);
|
|
40
|
+
expect(screen.getByText(/On a phone/i)).toBeTruthy();
|
|
41
|
+
expect(mintPreAuthKey).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('takes the control server from config, not from a minted key', async () => {
|
|
45
|
+
render(<TailnetEnroll />);
|
|
46
|
+
await waitFor(() =>
|
|
47
|
+
expect(screen.getByTestId('enroll-login-server').textContent).toContain('control.example.org'),
|
|
48
|
+
);
|
|
49
|
+
expect(fetchEnrollmentStatus).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('says what to do when no control server is configured', async () => {
|
|
53
|
+
vi.mocked(fetchEnrollmentStatus).mockResolvedValue({ login_server: null } as never);
|
|
54
|
+
render(<TailnetEnroll />);
|
|
55
|
+
await waitFor(() =>
|
|
56
|
+
expect(screen.getByTestId('enroll-login-server').textContent).toMatch(/not configured/i),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('keeps the Reset Keychain step visible — skipping it is why sign-in reverts to the public service', async () => {
|
|
61
|
+
render(<TailnetEnroll />);
|
|
62
|
+
expect(await screen.findByText(/Reset Keychain/i)).toBeTruthy();
|
|
63
|
+
});
|
|
64
|
+
|
|
29
65
|
it('lists mesh nodes', async () => {
|
|
30
66
|
render(<TailnetEnroll />);
|
|
31
67
|
await waitFor(() => expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('laptop'));
|
|
32
68
|
expect(screen.getByTestId('enroll-node-list')).toHaveTextContent('macmini');
|
|
33
69
|
});
|
|
34
70
|
|
|
35
|
-
it('mints a key and shows
|
|
71
|
+
it('mints a key and shows a QR to scan', async () => {
|
|
72
|
+
render(<TailnetEnroll />);
|
|
73
|
+
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
74
|
+
await waitFor(() => expect(screen.getByTestId('enroll-qr').querySelector('svg')).toBeTruthy());
|
|
75
|
+
expect(screen.getByTestId('enroll-copy')).toBeTruthy();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('config wins over the minted key for the control server URL', async () => {
|
|
79
|
+
// Deliberate precedence: the key echoes back whatever server issued it,
|
|
80
|
+
// but config is the source of truth and is available before any mint.
|
|
36
81
|
render(<TailnetEnroll />);
|
|
37
82
|
fireEvent.click(screen.getByTestId('enroll-mint'));
|
|
38
|
-
await waitFor(() => expect(screen.getByTestId('enroll-
|
|
39
|
-
expect(screen.getByTestId('enroll-
|
|
83
|
+
await waitFor(() => expect(screen.getByTestId('enroll-copy')).toBeTruthy());
|
|
84
|
+
expect(screen.getByTestId('enroll-login-server')).toHaveTextContent('control.example.org');
|
|
40
85
|
});
|
|
41
86
|
|
|
42
87
|
it('passes the reusable and ephemeral choices through to the API', async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { QRCodeSVG } from 'qrcode.react';
|
|
3
|
-
import { fetchNodes, mintPreAuthKey } from '../api/mesh';
|
|
3
|
+
import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
|
|
4
4
|
import type { MeshNode, PreAuthKey } from '../api/mesh';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -19,12 +19,20 @@ export function TailnetEnroll() {
|
|
|
19
19
|
const [busy, setBusy] = useState(false);
|
|
20
20
|
const [error, setError] = useState<string | null>(null);
|
|
21
21
|
const [copied, setCopied] = useState(false);
|
|
22
|
+
const [serverCopied, setServerCopied] = useState(false);
|
|
22
23
|
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
|
24
|
+
// The control-server URL is the FIRST thing you need on the phone, and it is
|
|
25
|
+
// config, not a property of a key. Reading it only off a minted key meant the
|
|
26
|
+
// URL stayed invisible until a 15-minute countdown was already running.
|
|
27
|
+
const [controlServer, setControlServer] = useState<string | null>(null);
|
|
23
28
|
|
|
24
29
|
useEffect(() => {
|
|
25
30
|
fetchNodes()
|
|
26
31
|
.then(setNodes)
|
|
27
32
|
.catch((e) => setError(e?.message ?? 'could not load nodes'));
|
|
33
|
+
fetchEnrollmentStatus()
|
|
34
|
+
.then((st) => setControlServer(st.login_server))
|
|
35
|
+
.catch(() => setControlServer(null));
|
|
28
36
|
}, []);
|
|
29
37
|
|
|
30
38
|
// An enrollment key is a bearer credential with a short TTL. Show the time
|
|
@@ -54,7 +62,7 @@ export function TailnetEnroll() {
|
|
|
54
62
|
};
|
|
55
63
|
|
|
56
64
|
const expired = secondsLeft !== null && secondsLeft <= 0;
|
|
57
|
-
const loginServer = minted?.login_server ?? null;
|
|
65
|
+
const loginServer = controlServer ?? minted?.login_server ?? null;
|
|
58
66
|
// `complete` is the resolver's own verdict: false when the login account is
|
|
59
67
|
// unknown and the node would refuse you. No resolved access at all counts as
|
|
60
68
|
// unknown too. (There is no `ssh_user` field — the account is `user`.)
|
|
@@ -64,9 +72,78 @@ export function TailnetEnroll() {
|
|
|
64
72
|
<section data-testid="tailnet-enroll" style={{ marginTop: 24 }}>
|
|
65
73
|
<h3 style={{ marginBottom: 4 }}>Add a device to the tailnet</h3>
|
|
66
74
|
<p style={{ fontSize: 12, opacity: 0.75, marginTop: 0 }}>
|
|
67
|
-
|
|
75
|
+
Point the device at this control server, then authenticate it with a short-lived key.
|
|
68
76
|
</p>
|
|
69
77
|
|
|
78
|
+
{/*
|
|
79
|
+
Instructions come BEFORE the key and are open by default. They used to
|
|
80
|
+
live inside the `minted` branch behind a collapsed <details>, so the
|
|
81
|
+
control-server URL -- step one, and the thing you cannot proceed
|
|
82
|
+
without -- was invisible until a 15-minute countdown was already
|
|
83
|
+
running. Nothing here is hardcoded: the URL is whatever the brain's
|
|
84
|
+
config resolves to.
|
|
85
|
+
*/}
|
|
86
|
+
<div
|
|
87
|
+
data-testid="enroll-instructions"
|
|
88
|
+
style={{ margin: '12px 0', padding: 14, borderRadius: 8, border: '1px solid var(--border)', background: 'var(--bg-secondary)' }}
|
|
89
|
+
>
|
|
90
|
+
<div style={{ fontSize: 12, marginBottom: 10 }}>
|
|
91
|
+
<strong>Control server</strong>
|
|
92
|
+
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginTop: 4 }}>
|
|
93
|
+
<code data-testid="enroll-login-server" style={{ wordBreak: 'break-all', flex: 1 }}>
|
|
94
|
+
{loginServer ?? 'not configured — set the Headscale URL in Secrets first'}
|
|
95
|
+
</code>
|
|
96
|
+
{loginServer && (
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
data-testid="enroll-copy-server"
|
|
100
|
+
onClick={() => { navigator.clipboard?.writeText(loginServer); setServerCopied(true); }}
|
|
101
|
+
>
|
|
102
|
+
{serverCopied ? 'Copied' : 'Copy'}
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<details open style={{ fontSize: 12 }}>
|
|
109
|
+
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>On a phone (iOS)</summary>
|
|
110
|
+
<ol style={{ paddingLeft: 18, lineHeight: 1.7, marginBottom: 6 }}>
|
|
111
|
+
<li>Install <strong>Tailscale</strong> from the App Store — <strong>1.38.1 or newer</strong>, the first release that supports an alternate control server.</li>
|
|
112
|
+
<li>
|
|
113
|
+
<strong>Sign out of Tailscale first.</strong> The alternate-server setting is only read while
|
|
114
|
+
the app is logged out.
|
|
115
|
+
</li>
|
|
116
|
+
<li>
|
|
117
|
+
Open <strong>iOS Settings</strong>, scroll past Game Center and TV Provider to{' '}
|
|
118
|
+
<strong>Tailscale</strong>, and put the control server above into{' '}
|
|
119
|
+
<strong>Alternate Coordination Server URL</strong>.
|
|
120
|
+
</li>
|
|
121
|
+
<li>
|
|
122
|
+
<strong>If this device was ever signed into Tailscale's own service, turn on “Reset Keychain”.</strong>{' '}
|
|
123
|
+
Skipping this is the usual reason the app keeps going back to the public login.
|
|
124
|
+
</li>
|
|
125
|
+
<li>Force-quit Tailscale from the app switcher, then reopen it — the setting is read at launch.</li>
|
|
126
|
+
<li>Tap the plain <strong>Sign in</strong> option (not SSO). It should open this control server's page.</li>
|
|
127
|
+
<li>Authenticate with the key below.</li>
|
|
128
|
+
</ol>
|
|
129
|
+
<p style={{ opacity: 0.75, margin: 0 }}>
|
|
130
|
+
The in-app route — account icon → <strong>Log in…</strong> → options menu →{' '}
|
|
131
|
+
<strong>Use custom coordination server</strong> — reaches the same place when it works, but that
|
|
132
|
+
menu has been reported unresponsive on some builds. The Settings route above is the reliable one.
|
|
133
|
+
</p>
|
|
134
|
+
</details>
|
|
135
|
+
|
|
136
|
+
<details style={{ fontSize: 12, marginTop: 8 }}>
|
|
137
|
+
<summary style={{ cursor: 'pointer', fontWeight: 600 }}>On Android</summary>
|
|
138
|
+
<ol style={{ paddingLeft: 18, lineHeight: 1.7, margin: '4px 0 0' }}>
|
|
139
|
+
<li>Install Tailscale from Google Play and make sure it is signed out.</li>
|
|
140
|
+
<li>Open the app, tap the account icon, then <strong>Log in…</strong>.</li>
|
|
141
|
+
<li>Open the top-right options menu → <strong>Use custom coordination server</strong>.</li>
|
|
142
|
+
<li>Enter the control server above, then authenticate with the key below.</li>
|
|
143
|
+
</ol>
|
|
144
|
+
</details>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
70
147
|
{error && (
|
|
71
148
|
<div
|
|
72
149
|
role="alert"
|
|
@@ -104,11 +181,6 @@ export function TailnetEnroll() {
|
|
|
104
181
|
</div>
|
|
105
182
|
|
|
106
183
|
<div style={{ flex: 1, minWidth: 260 }}>
|
|
107
|
-
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
|
108
|
-
<strong>Control server</strong>
|
|
109
|
-
<div><code data-testid="enroll-login-server">{loginServer ?? 'not configured'}</code></div>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
184
|
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
|
113
185
|
<strong>Enrollment key</strong>{' '}
|
|
114
186
|
<span data-testid="enroll-ttl" style={{ opacity: 0.7 }}>
|
|
@@ -127,20 +199,6 @@ export function TailnetEnroll() {
|
|
|
127
199
|
</div>
|
|
128
200
|
</div>
|
|
129
201
|
|
|
130
|
-
<details style={{ fontSize: 12, marginTop: 10 }}>
|
|
131
|
-
<summary style={{ cursor: 'pointer' }}>On a phone (iOS / Android)</summary>
|
|
132
|
-
<ol style={{ paddingLeft: 18, lineHeight: 1.6 }}>
|
|
133
|
-
<li>Install Tailscale (iOS needs 1.38.1 or newer).</li>
|
|
134
|
-
<li>Account icon → <strong>Log in</strong> → options menu → <strong>Use custom coordination server</strong>.</li>
|
|
135
|
-
<li>Enter <code>{loginServer ?? 'your control server URL'}</code>.</li>
|
|
136
|
-
<li>Scan the QR or paste the key above when asked to authenticate.</li>
|
|
137
|
-
</ol>
|
|
138
|
-
<p style={{ opacity: 0.75 }}>
|
|
139
|
-
If that menu does not respond (reported on some 2026 iOS builds), set the same URL under
|
|
140
|
-
iOS Settings → Tailscale → <em>Alternate coordination server URL</em>.
|
|
141
|
-
</p>
|
|
142
|
-
</details>
|
|
143
|
-
|
|
144
202
|
<details style={{ fontSize: 12, marginTop: 6 }}>
|
|
145
203
|
<summary style={{ cursor: 'pointer' }}>On a computer</summary>
|
|
146
204
|
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|