1import { bindInstallControl, installControlMarkup } from './install';2import { renderSecretCommand } from './secrets';3import {4 api,5 element,6 errorMessage,7 escapeHtml,8 query,9 setPageTitle,10} from './shared';11import { clearTheme } from './theme';1213type Visibility = 'public' | 'private' | 'shared';14type SiteKind = 'link' | 'service';1516interface ShareRecipient {17 handle: string;18 status: string;19}2021interface Resource {22 resource_id: string;23 name: string;24 kind: SiteKind;25 visibility: Visibility;26 shares?: ShareRecipient[];27 shared_with?: string[];28}2930interface IncomingShare {31 resource_id: string;32 name: string;33 kind: SiteKind;34 url?: string;35 owner_handle: string;36 status: string;37}3839interface MachineCredential {40 id: string;41 name: string;42 last_used_at: number | null;43 scopes?: string[];44}4546interface NewCredential {47 name: string;48 ticket: string;49}5051interface FixiEventDetail {52 cfg: {53 response: Response;54 text: string;55 };56 error?: unknown;57}5859const main = element('main');6061export function renderClaimHandle() {62 setPageTitle('dashboard');63 clearTheme();64 main.innerHTML = `65 <article>66 <hgroup>67 <h1>Choose a handle.</h1>68 <p>It becomes the address of your profile. Letters, digits, hyphens and underscores.</p>69 </hgroup>70 <form id="claim-form">71 <fieldset role="group">72 <input id="handle" name="handle" placeholder="yourhandle" autocomplete="off"73 spellcheck="false" aria-label="Handle" required>74 <button type="submit">Claim</button>75 </fieldset>76 <small id="claim-note"></small>77 </form>78 </article>`;7980 element('claim-form').addEventListener('submit', async (event) => {81 event.preventDefault();82 const field = element<HTMLInputElement>('handle');83 const note = element('claim-note');84 try {85 await api<{ handle: string }>('/api/profile', {86 method: 'POST',87 body: JSON.stringify({ handle: field.value }),88 });89 location.assign('/');90 } catch (error) {91 field.setAttribute('aria-invalid', 'true');92 note.innerHTML = `<span class="error">${escapeHtml(errorMessage(error))}</span>`;93 }94 });95}9697function formatTime(seconds: number | null): string {98 return seconds ? new Date(seconds * 1000).toLocaleString() : 'never';99}100101function resourceControl(resource: Resource): HTMLLIElement {102 const item = document.createElement('li');103 const shares = resource.shares || (resource.shared_with || [])104 .map((handle) => ({ handle, status: 'accepted' }));105 item.innerHTML = `106 <div>107 <strong>${escapeHtml(resource.name)}</strong>108 <small>${escapeHtml(resource.kind)} - ${escapeHtml(resource.visibility)}</small>109 </div>110 <div class="dashboard-actions"></div>`;111 const actions = query<HTMLElement>('.dashboard-actions', item);112 for (const share of shares) {113 const button = document.createElement('button');114 button.className = 'secondary outline revoke-share';115 button.type = 'button';116 button.textContent = `Revoke @${share.handle} - ${share.status}`;117 button.addEventListener('click', async () => {118 button.setAttribute('aria-busy', 'true');119 try {120 await api(`/api/resources/${encodeURIComponent(resource.resource_id)}/shares`, {121 method: 'PUT',122 body: JSON.stringify({123 share_with: shares124 .filter((value) => value.handle !== share.handle)125 .map((value) => value.handle),126 }),127 });128 await showDashboard();129 } catch (error) {130 button.removeAttribute('aria-busy');131 button.setAttribute('aria-invalid', 'true');132 button.title = errorMessage(error);133 }134 });135 actions.append(button);136 }137 if (!shares.length) actions.innerHTML = '<small>No individual shares</small>';138 return item;139}140141function configureFixiShareAction(142 button: HTMLButtonElement,143 action: string,144 method: 'POST' | 'DELETE',145) {146 button.setAttribute('fx-action', action);147 button.setAttribute('fx-method', method);148 button.setAttribute('fx-target', '#incoming-shares');149 button.setAttribute('fx-swap', 'innerHTML');150}151152function shareActionButton(event: Event): HTMLButtonElement | null {153 const button = event.target;154 if (!(button instanceof HTMLButtonElement)) return null;155 return button.getAttribute('fx-action')?.startsWith('/ui/share-invitations/')156 ? button157 : null;158}159160document.addEventListener('fx:before', (event) => {161 shareActionButton(event)?.setAttribute('aria-busy', 'true');162});163164document.addEventListener('fx:after', (event) => {165 const button = shareActionButton(event);166 if (!button) return;167 const detail = (event as CustomEvent<FixiEventDetail>).detail;168 if (detail.cfg.response.ok) return;169 event.preventDefault();170 button.setAttribute('aria-invalid', 'true');171 try {172 const body = JSON.parse(detail.cfg.text) as { error?: string };173 button.title = body.error ?? `${detail.cfg.response.status}`;174 } catch {175 button.title = `${detail.cfg.response.status}`;176 }177});178179document.addEventListener('fx:error', (event) => {180 const button = shareActionButton(event);181 if (!button) return;182 const detail = (event as CustomEvent<FixiEventDetail>).detail;183 button.setAttribute('aria-invalid', 'true');184 button.title = errorMessage(detail.error);185});186187document.addEventListener('fx:finally', (event) => {188 shareActionButton(event)?.removeAttribute('aria-busy');189});190191function incomingShareControl(share: IncomingShare): HTMLLIElement {192 const item = document.createElement('li');193 const destination = share.url194 ? ` - <span data-selectable>${escapeHtml(share.url)}</span>`195 : '';196 item.innerHTML = `197 <div>198 <strong>${escapeHtml(share.name)}</strong>199 <small>from @${escapeHtml(share.owner_handle)} - ${escapeHtml(share.kind)}${destination}</small>200 </div>201 <div class="dashboard-actions"></div>`;202 const actions = query<HTMLElement>('.dashboard-actions', item);203204 if (share.status === 'pending') {205 const accept = document.createElement('button');206 accept.type = 'button';207 accept.textContent = 'Accept';208 configureFixiShareAction(209 accept,210 `/ui/share-invitations/${encodeURIComponent(share.resource_id)}/accept`,211 'POST',212 );213 actions.append(accept);214 }215216 const decline = document.createElement('button');217 decline.className = 'secondary outline';218 decline.type = 'button';219 decline.textContent = share.status === 'pending' ? 'Decline' : 'Remove';220 configureFixiShareAction(221 decline,222 `/ui/share-invitations/${encodeURIComponent(share.resource_id)}`,223 'DELETE',224 );225 actions.append(decline);226 return item;227}228229function credentialControl(credential: MachineCredential): HTMLLIElement {230 const item = document.createElement('li');231 const scopes = credential.scopes?.length232 ? ` - ${credential.scopes.map((scope) => escapeHtml(scope)).join(', ')}`233 : '';234 item.innerHTML = `235 <div>236 <strong>${escapeHtml(credential.name)}</strong>237 <small>last used ${escapeHtml(formatTime(credential.last_used_at))}${scopes}</small>238 </div>`;239 const button = document.createElement('button');240 button.className = 'secondary outline';241 button.type = 'button';242 button.textContent = 'Revoke';243 button.addEventListener('click', async () => {244 button.setAttribute('aria-busy', 'true');245 try {246 await api(`/api/machine-credentials/${encodeURIComponent(credential.id)}`, {247 method: 'DELETE',248 });249 await showDashboard();250 } catch (error) {251 button.removeAttribute('aria-busy');252 button.setAttribute('aria-invalid', 'true');253 button.title = errorMessage(error);254 }255 });256 item.append(button);257 return item;258}259260let currentHandle = '';261let signOutCallback: (() => void) | undefined;262263async function signOut(button: HTMLButtonElement) {264 button.setAttribute('aria-busy', 'true');265 try {266 await api('/api/auth/session', { method: 'DELETE' });267 } catch (error) {268 button.removeAttribute('aria-busy');269 button.setAttribute('aria-invalid', 'true');270 button.title = errorMessage(error);271 return;272 }273 signOutCallback?.();274}275276export async function showDashboard(277 handle = currentHandle,278 onSignOut = signOutCallback,279 newCredential: NewCredential | null = null,280) {281 if (!handle) throw new Error('the dashboard needs a profile handle');282 currentHandle = handle;283 signOutCallback = onSignOut;284 setPageTitle('dashboard');285 clearTheme();286 main.setAttribute('aria-busy', 'true');287 const [resourceListing, incomingListing, credentialListing, settings] = await Promise.all([288 api<{ resources: Resource[] }>('/api/resources'),289 api<{ shares: IncomingShare[] }>('/api/share-invitations'),290 api<{ credentials: MachineCredential[] }>('/api/machine-credentials'),291 api<{ private_only: boolean }>('/api/profile/settings'),292 ]);293294 main.innerHTML = `295 <hgroup>296 <h1>@${escapeHtml(handle)}</h1>297 <p>Manage what your profile reveals and which machines may change it.</p>298 </hgroup>299300 <article>301 <header><strong>Profile</strong></header>302 <p><a href="/@${escapeHtml(handle)}">View your profile</a></p>303 <label>304 <input id="private-only" type="checkbox" role="switch"305 ${settings.private_only ? 'checked' : ''}>306 Private-only profile307 </label>308 <small>When enabled, only you can open the profile page. Services accepted by other309 people still appear as ordinary entries on their own profiles.</small>310 <p id="profile-settings-status"></p>311 </article>312313 <article>314 <details>315 <summary><strong>Incoming shares</strong></summary>316 <p>Nothing is added to your profile until you accept it. Link destinations are shown317 as text so you can inspect them without opening them.</p>318 <ul class="dashboard-list" id="incoming-shares"></ul>319 </details>320 </article>321322 <article>323 <details>324 <summary><strong>Shared by you</strong></summary>325 <ul class="dashboard-list" id="dashboard-resources"></ul>326 </details>327 </article>328329 <article>330 <details${newCredential ? ' open' : ''}>331 <summary><strong>Machine credentials</strong></summary>332 <p>Create a single-use ticket that enrolls one Ed25519 machine identity.</p>333 <form id="credential-form">334 <fieldset role="group">335 <input id="credential-name" name="name" maxlength="60"336 placeholder="This MacBook" aria-label="Machine name" required>337 <button type="submit">Create</button>338 </fieldset>339 <label>340 <input id="credential-service-grants" type="checkbox">341 Allow this machine to issue endpoint-bound service grants342 </label>343 <small>This authority is server-enforced and can be removed by revoking the344 machine credential.</small>345 <small id="credential-note"></small>346 </form>347 <div id="new-machine-token"></div>348 <ul class="dashboard-list" id="machine-credentials"></ul>349 </details>350 </article>351352 <article aria-labelledby="install-title">353 <header id="install-title">Install</header>354 ${installControlMarkup()}355 </article>356357 <article>358 <header><strong>Session</strong></header>359 <button id="logout" type="button" class="secondary outline">Log out</button>360 </article>`;361362 const resources = element('dashboard-resources');363 if (resourceListing.resources.length) {364 resourceListing.resources.forEach((resource) => {365 resources.append(resourceControl(resource));366 });367 } else {368 resources.innerHTML = '<li><small>No resources yet. Add one with the CLI.</small></li>';369 }370371 const incoming = element('incoming-shares');372 if (incomingListing.shares.length) {373 incomingListing.shares.forEach((share) => {374 incoming.append(incomingShareControl(share));375 });376 } else {377 incoming.innerHTML = '<li><small>No incoming shares.</small></li>';378 }379380 const credentials = element('machine-credentials');381 if (credentialListing.credentials.length) {382 credentialListing.credentials.forEach((credential) => {383 credentials.append(credentialControl(credential));384 });385 } else {386 credentials.innerHTML = '<li><small>No machine credentials yet.</small></li>';387 }388389 if (newCredential) {390 element('new-machine-token').innerHTML = `391 <p><strong>${escapeHtml(newCredential.name)}</strong> is ready.</p>392 <div class="secret-command-slot"></div>`;393 renderSecretCommand(394 query('.secret-command-slot', element('new-machine-token')),395 'login',396 newCredential.ticket,397 );398 }399400 element('logout').addEventListener('click', (event) =>401 signOut(event.currentTarget as HTMLButtonElement));402 bindInstallControl();403404 element('private-only').addEventListener('change', async (event) => {405 const input = event.currentTarget as HTMLInputElement;406 const status = element('profile-settings-status');407 input.setAttribute('aria-busy', 'true');408 try {409 await api('/api/profile/settings', {410 method: 'PUT',411 body: JSON.stringify({ private_only: input.checked }),412 });413 status.innerHTML = '<small class="ok">Saved.</small>';414 } catch (error) {415 input.checked = !input.checked;416 status.innerHTML = `<small class="error">${escapeHtml(errorMessage(error))}</small>`;417 } finally {418 input.removeAttribute('aria-busy');419 }420 });421422 element('credential-form').addEventListener('submit', async (event) => {423 event.preventDefault();424 const form = event.currentTarget as HTMLFormElement;425 const button = query<HTMLButtonElement>('button', form);426 const note = element('credential-note');427 button.setAttribute('aria-busy', 'true');428 try {429 const created = await api<{ credential: { name: string }; ticket: string }>(430 '/api/machine-credentials',431 {432 method: 'POST',433 body: JSON.stringify({434 name: element<HTMLInputElement>('credential-name').value,435 scopes: element<HTMLInputElement>('credential-service-grants').checked436 ? ['service_grants:issue']437 : [],438 }),439 },440 );441 await showDashboard(442 currentHandle,443 signOutCallback,444 { name: created.credential.name, ticket: created.ticket },445 );446 } catch (error) {447 button.removeAttribute('aria-busy');448 note.innerHTML = `<span class="error">${escapeHtml(errorMessage(error))}</span>`;449 }450 });451452 main.removeAttribute('aria-busy');453}