gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit9101716491017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre91017164/selector.js

8.0 KB

  1. // selector.js — ident's IDENTITY SELECTOR for an app's page (piece 3 of 6, ticket #26;
  2. // CONCEPT.md "Flow 2: identity selector"). Served by ident at /selector.js. An app's page:
  3. //
  4. // <script src="https://<ident>/selector.js"></script>
  5. // <ident-selector key="pk_…"></ident-selector> (the app's PUBLIC API key)
  6. //
  7. // The element (shadow DOM, ident's design) first says "choose ident". Opening it fetches
  8. // the identities of the ident user signed in in this browser from ident (credentials,
  9. // CORS: only from one of the app's registered origins with its key). Choosing one IS the
  10. // login: ident hands a one-time code, the element fires `ident-login` with
  11. // `event.detail.code`; the HOST'S SERVER trades the code with POST /api/exchange (key +
  12. // secret) for the app-specific identity id.
  13. // The host tells the element it is logged in: attribute `logged-in` (or `el.loggedIn =
  14. // true`). Logout on the website = the host resets it (remove the attribute, or
  15. // `el.loggedIn = false` / `el.reset()`). The element sets no cookie of its own.
  16. // Restyling: CSS custom properties --ident-* on the element, and ::part(button | panel |
  17. // identity | status | message | link) — see ident's README "How apps use ident".
  18. (() => {
  19. if (!window.customElements || customElements.get('ident-selector')) return;
  20. const script = document.currentScript;
  21. const IDENT = new URL(script ? script.src : location.href).origin;
  22. const CSS = `
  23. :host {
  24. --_accent: var(--ident-accent, #ce9178);
  25. --_accent-text: var(--ident-accent-text, rgb(15, 20, 25));
  26. --_bg: var(--ident-background, rgb(35, 40, 45));
  27. --_text: var(--ident-text, rgb(195, 200, 205));
  28. --_strong: var(--ident-text-strong, rgb(245, 250, 255));
  29. --_muted: var(--ident-text-muted, rgb(145, 150, 155));
  30. --_border: var(--ident-border, rgb(70, 75, 80));
  31. --_radius: var(--ident-radius, .4rem);
  32. display: inline-block;
  33. max-width: 100%;
  34. font: var(--ident-font, 600 16px/1.5 system-ui, sans-serif);
  35. color: var(--_text);
  36. }
  37. :host([hidden]) { display: none; }
  38. * { box-sizing: border-box; }
  39. button { font: inherit; cursor: pointer; }
  40. [part~="button"] {
  41. display: inline-flex; align-items: center; gap: .5rem; max-width: 100%;
  42. padding: .6rem 1.1rem; color: var(--_accent-text); background: var(--_accent);
  43. border: 1px solid var(--_accent); border-radius: var(--_radius);
  44. }
  45. [part~="button"]:hover { filter: brightness(1.1); }
  46. [part~="button"]:focus-visible, [part~="identity"]:focus-visible { outline: 2px solid var(--_strong); outline-offset: 2px; }
  47. .mark { font-weight: 800; letter-spacing: .01em; }
  48. .caret { font-size: .75em; }
  49. [part~="panel"] {
  50. margin-top: .4rem; width: 20rem; max-width: 100%; padding: .5rem;
  51. background: var(--_bg); border: 1px solid var(--_border); border-radius: calc(var(--_radius) * 1.5);
  52. font-weight: 400;
  53. }
  54. ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .3rem; }
  55. [part~="identity"] {
  56. display: block; width: 100%; text-align: left; padding: .55rem .8rem; overflow-wrap: anywhere;
  57. color: var(--_strong); background: transparent; border: 1px solid var(--_border); border-radius: var(--_radius);
  58. }
  59. [part~="identity"]:hover { border-color: var(--_accent); color: var(--_accent); }
  60. .head { margin: 0 0 .4rem; font-size: .85rem; color: var(--_muted); }
  61. [part~="message"] { margin: 0; padding: .3rem .2rem; font-size: .95rem; }
  62. [part~="link"] { color: var(--_accent); }
  63. [part~="status"] {
  64. display: inline-flex; align-items: center; gap: .5rem; max-width: 100%; overflow-wrap: anywhere;
  65. padding: .6rem 1.1rem; color: var(--_strong); background: var(--_bg);
  66. border: 1px solid var(--_border); border-radius: var(--_radius);
  67. }
  68. [part~="status"] .check { color: var(--_accent); }
  69. `;
  70. const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
  71. class IdentSelector extends HTMLElement {
  72. static get observedAttributes() { return ['logged-in', 'key']; }
  73. constructor() {
  74. super();
  75. this.attachShadow({ mode: 'open' });
  76. this._open = false;
  77. this._view = 'idle'; // idle | loading | list | signedout | error
  78. this._items = [];
  79. this._error = '';
  80. this._chosen = '';
  81. this._render();
  82. }
  83. get key() { return this.getAttribute('key') || ''; }
  84. get loggedIn() { return this.hasAttribute('logged-in'); }
  85. set loggedIn(v) { this.toggleAttribute('logged-in', !!v); }
  86. reset() { this.loggedIn = false; }
  87. attributeChangedCallback(name) {
  88. if (name === 'logged-in' && !this.loggedIn) this._chosen = '';
  89. this._open = false;
  90. this._render();
  91. }
  92. async _toggle() {
  93. if (this._open) { this._open = false; this._render(); return; }
  94. this._open = true;
  95. this._view = 'loading';
  96. this._render();
  97. try {
  98. const r = await fetch(IDENT + '/api/selector/identities?key=' + encodeURIComponent(this.key), { credentials: 'include', cache: 'no-store' });
  99. if (!r.ok) throw new Error('HTTP ' + r.status);
  100. const j = await r.json();
  101. this._items = j.identities || [];
  102. this._view = j.signedIn ? 'list' : 'signedout';
  103. } catch (e) {
  104. // a refused origin/key has no CORS headers: the browser gives us nothing but this
  105. this._view = 'error';
  106. this._error = 'ident does not answer this site. Is it registered in ident with this API key?';
  107. }
  108. if (this._open) this._render();
  109. }
  110. async _choose(id, name) {
  111. this._view = 'loading';
  112. this._render();
  113. try {
  114. const r = await fetch(IDENT + '/api/selector/choose?key=' + encodeURIComponent(this.key), {
  115. method: 'POST', credentials: 'include', cache: 'no-store',
  116. headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identity: id }),
  117. });
  118. const j = await r.json().catch(() => ({}));
  119. if (!r.ok || !j.code) throw new Error(j.error || ('HTTP ' + r.status));
  120. this._chosen = name;
  121. this._open = false;
  122. this._render();
  123. // THE LOGIN: the host's server trades this code (POST /api/exchange, key + secret)
  124. this.dispatchEvent(new CustomEvent('ident-login', { detail: { code: j.code }, bubbles: true, composed: true }));
  125. } catch (e) {
  126. this._view = 'error';
  127. this._error = 'ident refused: ' + e.message;
  128. this._render();
  129. }
  130. }
  131. _render() {
  132. const root = this.shadowRoot;
  133. if (this.loggedIn) {
  134. root.innerHTML = `<style>${CSS}</style><span part="status" id="status"><span class="check" aria-hidden="true">✓</span>` +
  135. `<span>logged in with <span class="mark">ident</span>${this._chosen ? ' · ' + esc(this._chosen) : ''}</span></span>`;
  136. return;
  137. }
  138. let panel = '';
  139. if (this._open) {
  140. let body = '';
  141. if (this._view === 'loading') body = `<p part="message" id="message">…</p>`;
  142. else if (this._view === 'list') {
  143. body = `<p class="head">Choose the identity to log in with</p><ul id="list">` +
  144. this._items.map((it) => `<li><button type="button" part="identity" data-id="${esc(it.id)}">${esc(it.name)}</button></li>`).join('') + `</ul>`;
  145. } else if (this._view === 'signedout') {
  146. body = `<p part="message" id="message">You are not signed in to ident. <a part="link" id="signin" href="${esc(IDENT)}/" target="_blank" rel="noopener">Sign in to ident</a>, then choose again.</p>`;
  147. } else if (this._view === 'error') body = `<p part="message" id="message">${esc(this._error)}</p>`;
  148. panel = `<div part="panel" id="panel">${body}</div>`;
  149. }
  150. root.innerHTML = `<style>${CSS}</style><button type="button" part="button" id="choose" aria-expanded="${this._open}">` +
  151. `<span>choose <span class="mark">ident</span></span><span class="caret" aria-hidden="true">${this._open ? '▲' : '▼'}</span></button>${panel}`;
  152. root.getElementById('choose').addEventListener('click', () => this._toggle());
  153. for (const b of root.querySelectorAll('[part~="identity"]')) {
  154. b.addEventListener('click', () => this._choose(b.dataset.id, b.textContent));
  155. }
  156. }
  157. }
  158. customElements.define('ident-selector', IdentSelector);
  159. })();

Branches

Latest commits

  • 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
  • f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
  • ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre