clone

an rts arena for scripts: submit tribe strategies over the web or mcp, run deterministic matches, watch them

main
tribes / web / src / pages / connect.ts
connect.ts 11.7K
1/** Connect: account + sharing toggle, identities (authorized keys), examples, docs, danger zone. */2import type { KeyIdentity } from '@tribes/protocol';3import { api } from '../api';4import { signOut } from '../auth';5import { h, clear } from '../dom';6import type { AppCtx } from '../main';7import { signInCard, honorReturnRedirect } from './auth-card';89export function renderConnect(main: HTMLElement, ctx: AppCtx): void {10 if (!ctx.user) {11 main.append(signInCard(ctx));12 return;13 }14 // Signed in on the canonical auth origin on behalf of another host? Bounce back.15 if (honorReturnRedirect()) return;1617 // Two SEMANTIC columns: everything account-shaped on the left, everything18 // your agent needs on the right. Never mix the themes.19 const accountCard = h('div', { class: 'card' });20 const identitiesCard = h('div', { class: 'card' });21 const dangerCard = h('div', { class: 'card danger-zone' });22 const agentCard = h('div', { class: 'card' });23 const examplesCard = h('div', { class: 'card' });24 main.append(25 h('div', { class: 'home-grid' },26 h('div', { class: 'home-col' },27 h('h2', { class: 'col-title' }, '- your account -'),28 accountCard, identitiesCard, dangerCard),29 h('div', { class: 'home-col' },30 h('h2', { class: 'col-title' }, '- your agent -'),31 agentCard, examplesCard),32 ),33 );3435 paintAccount(accountCard, ctx);36 paintIdentities(identitiesCard);37 paintAgent(agentCard);38 void paintExamples(examplesCard).catch((e) => showErr(examplesCard, e));39 paintDanger(dangerCard, ctx);40}4142function showErr(card: HTMLElement, e: unknown) {43 card.append(h('p', { class: 'error-text' }, e instanceof Error ? e.message : String(e)));44}4546function fmtDate(iso: string | null): string {47 if (!iso) return '—';48 const d = new Date(iso);49 return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });50}5152// --------------------------------------------------------------------------53// Account54// --------------------------------------------------------------------------5556function paintAccount(card: HTMLElement, ctx: AppCtx) {57 const user = ctx.user!;58 const msg = h('span', { class: 'error-text' });59 const toggle = h('input', {60 type: 'checkbox',61 checked: user.publicSharing,62 onchange: async () => {63 msg.textContent = '';64 try {65 ctx.setUser(await api.patchMe({ publicSharing: toggle.checked }));66 } catch (err) {67 toggle.checked = !toggle.checked;68 msg.textContent = err instanceof Error ? err.message : String(err);69 }70 },71 });72 const nameInput = h('input', {73 type: 'text',74 value: user.name ?? '',75 placeholder: user.id,76 maxLength: 40,77 });78 const nameMsg = h('span', { class: 'muted' });79 card.append(80 h('h2', null, 'Account'),81 h('p', { class: 'row' }, h('span', { class: 'mono muted' }, user.id)),82 h(83 'div',84 { class: 'row', style: 'margin-bottom:0.5rem' },85 nameInput,86 h(87 'button',88 {89 class: 'small',90 onclick: async () => {91 nameMsg.textContent = '';92 try {93 const v = nameInput.value.trim();94 ctx.setUser(await api.patchMe({ name: v === '' ? null : v }));95 nameMsg.textContent = 'saved';96 } catch (err) {97 nameMsg.textContent = err instanceof Error ? err.message : String(err);98 }99 },100 },101 'Save name',102 ),103 nameMsg,104 ),105 h(106 'p',107 { class: 'muted' },108 user.nameLockedUntil !== null109 ? `Name changes are limited to once per 7 days - next change ${new Date(user.nameLockedUntil).toLocaleDateString()}.`110 : 'Your display name on Browse listings; blank shows your user id. Changable once per 7 days.',111 ),112 h(113 'label',114 { class: 'row toggle-row' },115 toggle,116 h('span', null, 'Public sharing'),117 msg,118 ),119 h('p', { class: 'muted' }, 'Starred scripts are listed publicly in Browse while enabled.'),120 h(121 'p',122 null,123 h(124 'button',125 {126 class: 'small',127 onclick: async () => {128 await signOut();129 ctx.setUser(null);130 },131 },132 'Sign out',133 ),134 ),135 );136}137138// --------------------------------------------------------------------------139// Docs140// --------------------------------------------------------------------------141142function paintAgent(card: HTMLElement) {143 card.append(144 h('h2', null, 'Connect your agent'),145 h(146 'p',147 { class: 'muted' },148 'No signup: the agent mints an ed25519 keypair, signs in with it, and starts submitting strategies.',149 ),150 h(151 'pre',152 { class: 'snippet' },153 [154 `# how to ${location.origin}/auth.md (identity file + mint snippet)`,155 `# docs ${location.origin}/llms.txt (full scripting reference)`,156 `# MCP ${location.origin}/mcp (streamable HTTP, bearer token)`,157 `# REST ${location.origin}/api/*`,158 ].join('\n'),159 ),160 h(161 'p',162 { class: 'muted' },163 'Read the reference: ',164 h('a', { href: '/llms.txt', target: '_blank' }, '/llms.txt'),165 ' - identity convention: ',166 h('a', { href: '/auth.md', target: '_blank' }, '/auth.md'),167 '.',168 ),169 );170}171172// --------------------------------------------------------------------------173// Examples174// --------------------------------------------------------------------------175176async function paintExamples(card: HTMLElement) {177 card.append(h('h2', null, 'Examples'), h('p', { class: 'muted' }, 'Loading…'));178 const examples = await api.listExamples();179 clear(card);180 card.append(181 h('h2', null, 'Examples'),182 h(183 'p',184 { class: 'muted' },185 'Frozen system scripts — copy the source and make it yours (upload on the Create tab). ',186 "The 'primitives' library isn't runnable alone; paste its helpers above your tick().",187 ),188 );189 for (const ex of examples) {190 const pre = h('pre', { class: 'source', style: 'display:none' }, ex.source);191 const viewBtn = h(192 'button',193 {194 class: 'small',195 onclick: () => {196 const hidden = pre.style.display === 'none';197 pre.style.display = hidden ? 'block' : 'none';198 viewBtn.textContent = hidden ? 'Hide source' : 'View source';199 },200 },201 'View source',202 );203 const copyBtn = h(204 'button',205 {206 class: 'small',207 onclick: async () => {208 await navigator.clipboard.writeText(ex.source);209 copyBtn.textContent = 'copied!';210 setTimeout(() => (copyBtn.textContent = 'Copy source'), 1200);211 },212 },213 'Copy source',214 );215 card.append(216 h(217 'div',218 { class: 'example-row' },219 h('div', { class: 'row' }, h('strong', null, ex.name), h('span', { class: 'muted' }, ` ${ex.kind} - ${ex.description}`)),220 h('div', { class: 'row', style: 'margin:0.3rem 0' }, viewBtn, copyBtn),221 pre,222 ),223 );224 }225}226227// --------------------------------------------------------------------------228// Danger zone229// --------------------------------------------------------------------------230231function paintDanger(card: HTMLElement, ctx: AppCtx) {232 const user = ctx.user!;233 const msg = h('p', { class: 'error-text' });234 const confirmInput = h('input', {235 type: 'text',236 placeholder: user.id,237 oninput: () => (deleteBtn.disabled = confirmInput.value.trim() !== user.id),238 });239 const deleteBtn = h(240 'button',241 {242 class: 'danger',243 disabled: true,244 onclick: async () => {245 msg.textContent = '';246 try {247 await api.deleteAccount(confirmInput.value.trim());248 ctx.setUser(null);249 ctx.navigate('/browse');250 } catch (err) {251 msg.textContent = err instanceof Error ? err.message : String(err);252 }253 },254 },255 'Delete account',256 );257 card.append(258 h('h2', null, 'Danger zone'),259 h(260 'p',261 { class: 'muted' },262 'Deleting your account removes your scripts, sims, stars, hearts, and tokens. This cannot be undone. ',263 'Type your user id (',264 h('code', { class: 'mono' }, user.id),265 ') to confirm.',266 ),267 h('div', { class: 'row' }, confirmInput, deleteBtn),268 msg,269 );270}271272// --------------------------------------------------------------------------273// Identities (authorized keys)274// --------------------------------------------------------------------------275276function paintIdentities(card: HTMLElement) {277 clear(card);278 card.append(279 h('h2', null, 'Identities'),280 h('p', { class: 'muted' }, 'The ways you can sign in to this account.'),281 );282 const listWrap = h('div');283284 async function refresh() {285 try {286 const keys = await api.listKeys();287 paintList(keys);288 } catch (err) {289 clear(listWrap);290 listWrap.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));291 }292 }293294 // Humans never need the full identifier - a short label tells keys apart;295 // the full value lives in the title tooltip.296 function labelFor(k: KeyIdentity): string {297 if (k.kind === 'key') return `device ${k.externalId.slice(4, 12)}`;298 if (k.kind === 'dev') return k.externalId;299 return 'shoo account';300 }301302 function paintList(keys: KeyIdentity[]) {303 clear(listWrap);304 listWrap.append(305 h(306 'table',307 { class: 'list' },308 h(309 'tbody',310 null,311 keys.map((k) =>312 h(313 'tr',314 null,315 h('td', { title: k.externalId }, labelFor(k)),316 h('td', { class: 'muted' }, `added ${fmtDate(k.createdAt)}`),317 h(318 'td',319 { style: 'text-align:right' },320 k.kind === 'key' && keys.length > 1321 ? h(322 'button',323 {324 class: 'small danger',325 onclick: async () => {326 if (!confirm('Revoke this device key? Its access stops immediately.')) return;327 await api.removeKey(k.externalId.slice(4));328 await refresh();329 },330 },331 'revoke',332 )333 : h('span', { class: 'muted' }, keys.length <= 1 ? 'last identity' : ''),334 ),335 ),336 ),337 ),338 ),339 );340 }341342 card.append(343 listWrap,344 h('h3', { style: 'margin-top:1.2rem' }, 'Link a device'),345 h(346 'p',347 { class: 'muted' },348 'Working from another machine or an agent? Generate a pairing code and redeem it there - that device becomes this account.',349 ),350 (() => {351 const codeOut = h('div');352 return h(353 'div',354 null,355 h(356 'button',357 {358 onclick: async () => {359 clear(codeOut);360 try {361 const { code } = await api.mintLinkCode();362 codeOut.append(363 h(364 'div',365 { class: 'token-once' },366 h('p', { class: 'row' }, h('code', { class: 'mono' }, code)),367 h(368 'p',369 { class: 'muted' },370 'Single use, valid 10 minutes. On the device, tell your agent: "link this device to my tribes account with code ' + code + '".',371 ),372 ),373 );374 } catch (err) {375 codeOut.append(h('p', { class: 'error-text' }, err instanceof Error ? err.message : String(err)));376 }377 },378 },379 'Generate link code',380 ),381 codeOut,382 );383 })(),384 );385386 void refresh();387}