Settings.tsx
⎇
Raw
1import { createResource, createSignal, For, Show } from "solid-js";
2import { api, type ShareInfo, type TokenInfo } from "./api";
3import { ago, until } from "./live";
4import { useSession } from "./session";
5
6/**
7 * The only place individual phones are visible.
8 *
9 * Positions belong to the account, so a token is a credential and nothing else.
10 * Listing them with last-seen is what makes a forgotten phone in a drawer — the
11 * one that drags the live marker back and forth — visible and revocable.
12 */
13export default function Settings() {
14 const me = useSession().me;
15 const [tokens, { refetch }] = createResource(() => api.tokens());
16 const [message, setMessage] = createSignal<string | null>(null);
17 const now = Math.floor(Date.now() / 1000);
18
19 async function revoke(t: TokenInfo) {
20 if (!confirm(`Revoke "${t.name}"? That phone has to log in again.`)) return;
21 await api.revokeToken(t.token_id);
22 void refetch();
23 }
24
25 async function revokeOthers() {
26 if (!confirm("Revoke every device token on this account?")) return;
27 const { revoked } = await api.revokeOthers();
28 setMessage(`${revoked} token(s) revoked`);
29 void refetch();
30 }
31
32 return (
33 <main class="settings">
34 <section class="card">
35 <h2>account</h2>
36 <p class="muted">
37 {me.display_name} ({me.username}){me.is_admin ? " · admin" : ""}
38 </p>
39 <PasswordForm />
40 </section>
41
42 <section class="card">
43 <h2>devices</h2>
44 <Show when={message()}>{(m) => <p class="muted">{m()}</p>}</Show>
45 <For each={tokens()} fallback={<p class="muted">no devices logged in</p>}>
46 {(t) => (
47 <div class="token">
48 <div>
49 <strong>{t.name}</strong> <span class="muted">{t.platform}</span>
50 <div class="muted">
51 {t.last_seen_at ? `seen ${ago(t.last_seen_at, now)} ago` : "never seen"}
52 {t.last_src_ip ? ` · ${t.last_src_ip}` : ""}
53 {t.last_transport ? ` · ${t.last_transport}` : ""}
54 {t.os_api_level ? ` · API ${t.os_api_level}` : ""}
55 </div>
56 </div>
57 <button onClick={() => revoke(t)}>revoke</button>
58 </div>
59 )}
60 </For>
61 <button onClick={revokeOthers}>log out all devices</button>
62 </section>
63
64 <Sharing />
65 </main>
66 );
67}
68
69const PRECISIONS = [
70 { m: 0, label: "exact" },
71 { m: 100, label: "100 m" },
72 { m: 500, label: "500 m" },
73 { m: 1000, label: "1 km" },
74 { m: 5000, label: "5 km" },
75];
76
77const EXPIRIES = [
78 { s: null, label: "never" },
79 { s: 3600, label: "1 hour" },
80 { s: 8 * 3600, label: "8 hours" },
81 { s: 24 * 3600, label: "24 hours" },
82 { s: 7 * 86400, label: "7 days" },
83];
84
85function precisionWords(m: number): string {
86 if (m === 0) return "exact";
87 return m < 1000 ? `rounded to ${m} m` : `rounded to ${m / 1000} km`;
88}
89
90/**
91 * Outgoing shares: who can see this account, and how much.
92 *
93 * The whole grant is on one row because the three settings only mean anything
94 * together — "bob, live position only, rounded to 1 km" is the sentence a user
95 * has to be able to check at a glance before trusting it.
96 */
97function Sharing() {
98 const [shares, { refetch }] = createResource(() => api.shares());
99 const [username, setUsername] = createSignal("");
100 const [trailVisible, setTrailVisible] = createSignal(true);
101 const [precision, setPrecision] = createSignal(0);
102 const [expiresIn, setExpiresIn] = createSignal<number | null>(null);
103 const [error, setError] = createSignal<string | null>(null);
104 const now = Math.floor(Date.now() / 1000);
105
106 async function create(e: Event) {
107 e.preventDefault();
108 setError(null);
109 try {
110 await api.createShare({
111 username: username(),
112 trail_visible: trailVisible(),
113 precision_m: precision(),
114 expires_in_s: expiresIn(),
115 });
116 setUsername("");
117 setTrailVisible(true);
118 setPrecision(0);
119 setExpiresIn(null);
120 void refetch();
121 } catch (e) {
122 setError(e instanceof Error ? e.message : String(e));
123 }
124 }
125
126 async function revoke(sh: ShareInfo) {
127 if (!confirm(`Stop sharing with ${sh.viewer_display_name}?`)) return;
128 await api.revokeShare(sh.id);
129 void refetch();
130 }
131
132 return (
133 <section class="card">
134 <h2>sharing</h2>
135 <For each={shares()} fallback={<p class="muted">you are not sharing with anyone</p>}>
136 {(sh) => (
137 <div class="share">
138 <div>
139 <strong>{sh.viewer_display_name}</strong> <span class="muted">{sh.viewer_username}</span>
140 <div class="muted">
141 {sh.trail_visible ? "trail visible" : "live position only"}
142 {` · ${precisionWords(sh.precision_m)}`}
143 {sh.expires_at === null ? "" : ` · expires in ${until(sh.expires_at, now)}`}
144 </div>
145 </div>
146 <button onClick={() => revoke(sh)}>stop</button>
147 </div>
148 )}
149 </For>
150
151 <form class="share-form" onSubmit={create}>
152 <input placeholder="username to share with" autocomplete="off" value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
153 <label>
154 <input type="checkbox" checked={trailVisible()} onChange={(e) => setTrailVisible(e.currentTarget.checked)} />
155 show my trail, not just where I am now
156 </label>
157 <label>
158 precision
159 <select value={precision()} onChange={(e) => setPrecision(Number(e.currentTarget.value))}>
160 <For each={PRECISIONS}>{(p) => <option value={p.m}>{p.label}</option>}</For>
161 </select>
162 </label>
163 <p class="muted">precision blurs your position before they see it, so they get the area and not the address.</p>
164 <label>
165 expires
166 {/* A select cannot produce an out-of-range value, so the server's 400
167 for a bad precision or expiry is unreachable from this form. */}
168 <select value={String(expiresIn())} onChange={(e) => setExpiresIn(e.currentTarget.value === "null" ? null : Number(e.currentTarget.value))}>
169 <For each={EXPIRIES}>{(x) => <option value={String(x.s)}>{x.label}</option>}</For>
170 </select>
171 </label>
172 <Show when={error()}>{(msg) => <p class="error">{msg()}</p>}</Show>
173 <button>share</button>
174 </form>
175 </section>
176 );
177}
178
179function PasswordForm() {
180 const [current, setCurrent] = createSignal("");
181 const [next, setNext] = createSignal("");
182 const [status, setStatus] = createSignal<string | null>(null);
183
184 async function submit(e: Event) {
185 e.preventDefault();
186 try {
187 await api.changePassword(current(), next());
188 // Changing the password revokes every device token, by design.
189 setStatus("changed — every device must log in again");
190 setCurrent("");
191 setNext("");
192 } catch (e) {
193 setStatus(e instanceof Error ? e.message : String(e));
194 }
195 }
196
197 return (
198 <form class="password" onSubmit={submit}>
199 <input type="password" placeholder="current password" autocomplete="current-password" value={current()} onInput={(e) => setCurrent(e.currentTarget.value)} />
200 <input type="password" placeholder="new password (10+ characters)" autocomplete="new-password" value={next()} onInput={(e) => setNext(e.currentTarget.value)} />
201 <button>change password</button>
202 <Show when={status()}>{(s) => <p class="muted">{s()}</p>}</Show>
203 </form>
204 );
205}
206