gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit8bebbbf28bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre8bebbbf2/components/home.hl

21.1 KB

  1. // components/home.hl — `/` and `/signin/:rid`: ALL OF IDENT (pieces 1+2, tickets #24 #25;
  2. // CONCEPT.md).
  3. // Signed out: email → a six-digit code by mail → signed in. There is no registration:
  4. // the first right code for an address creates the account with a DEFAULT IDENTITY, then
  5. // this page shows that identity's name fields and says they are optional (Skip).
  6. // Signed in: the account's identities (list, new, edit, delete — never the last one)
  7. // and its time zone (the browser's at the first login; changeable here).
  8. //
  9. // Under `/signin/<rid>` the page belongs to an APP'S LOGIN BUTTON (apps.hl requestOf):
  10. // it names the app, signs in to ident if needed, then asks WHICH IDENTITY the app gets
  11. // (with a single identity it still shows which one, one click) and sends the browser back
  12. // to the app with a one-time code. Managing identities stays on `/`.
  13. //
  14. // Every step takes the server's answer from the CALL (the value form of emit): it rides
  15. // the ack, over the socket or the POST fallback.
  16. parent './main.hl'
  17. import { checkCode, accountOfSession, recordPending, pendingOf, dropPending, identityRows, createIdentity, updateIdentity, deleteIdentity, setTimeZone, beginSession, signOutEverywhere } from '../store.hl'
  18. import { requestOf, grantLogin } from '../apps.hl'
  19. import { grantInvite } from '../invites.hl'
  20. import { siteName } from '../project.hl'
  21. session = null
  22. rid = null
  23. // 'code' on THE CODE PAGE `/code` and `/signin/<rid>/code` (ident#20; project.hl codePage
  24. // renders this component there, only while the session has a pending code), else null
  25. step = null
  26. onCodePage = step == 'code'
  27. // where this login lives: the email form, and the code page after "Send me a code"
  28. homeUrl = rid != null ? '/signin/' + rid : '/'
  29. codeUrl = rid != null ? '/signin/' + rid + '/code' : '/code'
  30. me = accountOfSession(session)
  31. signedIn = me != null
  32. // THE APP'S LOGIN REQUEST (null on `/`, or when unknown / expired)
  33. request = rid != null ? requestOf(rid) : null
  34. forApp = request != null
  35. badRequest = rid != null && request == null
  36. appName = request != null ? request.app.name : ''
  37. appOrigin = request != null ? request.origin : ''
  38. forInvite = request != null && request.invite != ''
  39. requestLead = forInvite ? 'You are invited to ' : 'Sign in to '
  40. choosing = forApp && me != null // the identity choice for the app
  41. manage = rid == null && me != null // identities + time zone (only on `/`)
  42. meEmail = me != null ? me.email : ''
  43. timeZone = me != null ? me.timeZone : ''
  44. tzInput = timeZone
  45. identities = me != null ? identityRows(me.id) : []
  46. __title = siteName + (forApp ? ' | sign in to ' + appName : (me != null ? ' | your identities' : ' | sign in'))
  47. // the login steps (a View's `if` takes a member, so each step is one).
  48. // THE CODE STEP IS ITS OWN PAGE (ident#20, creator: "just make a /code where it checks a
  49. // pending code"): "Send me a code" records the address in this browser's session (face
  50. // rememberPending) and goes to `/code`; that page shows the code form for the session's
  51. // pending address (store.hl pendingOf) — so a reload, a second tab or a re-seed keeps it.
  52. pendingEmail = onCodePage && me == null && !badRequest ? pendingOf(session) : null
  53. askEmail = me == null && !badRequest && !onCodePage
  54. askCode = pendingEmail != null
  55. email = ''
  56. sentTo = pendingEmail != null ? pendingEmail : ''
  57. code = ''
  58. message = ''
  59. notice = ''
  60. // THE IDENTITY FORM — new, edit, and right after the first login the default identity
  61. // ("welcome": the names are optional, Skip closes it)
  62. editing = false
  63. welcome = false
  64. notWelcome = true // `if (!welcome)` in a View renders nothing: an `if` takes a member
  65. editId = '' // '' = a new identity, else the identity's id (a UUID)
  66. editHeading = ''
  67. fIdentityName = ''
  68. fNickname = ''
  69. fFirstname = ''
  70. fLastname = ''
  71. fAvatar = ''
  72. hasAvatarPreview = false
  73. avatarPreviewClass = hasAvatarPreview ? 'avatar' : 'avatar hidden'
  74. avatarRemoveClass = hasAvatarPreview ? 'quiet small' : 'quiet small hidden'
  75. View {
  76. identCard {
  77. if (forApp) {
  78. appRequest { id = "apprequest" requestLead strong { id = "appname" appName } " " requestOrigin { id = "apporigin" appOrigin } }
  79. }
  80. if (badRequest) {
  81. h1 { "Login request expired" }
  82. p { id = "badrequest" class = "message" "This login request is unknown or expired. Go back to the app and start the login again." }
  83. }
  84. if (askEmail) {
  85. h1 { "Sign in" }
  86. p { class = "lead" "We mail you a one-time code. There are no passwords, and no sign-up: the first login with an address creates its account." }
  87. form { id = "emailform"
  88. on submit(e) {
  89. e.preventDefault()
  90. emit ask(e)
  91. }
  92. label { "Email"
  93. input { id = "email" name = "email" type = "email" autocomplete = "email" required = "required" value = email on input(e) { emit setEmail(e.target.value) } }
  94. }
  95. button { id = "sendcode" type = "submit" "Send me a code" }
  96. }
  97. }
  98. if (askCode) {
  99. h1 { "Enter your code" }
  100. p { id = "sentto" class = "lead" "We sent a six-digit code to " strong { sentTo } ". It is valid for 10 minutes." }
  101. form { id = "codeform"
  102. on submit(e) {
  103. e.preventDefault()
  104. emit verify(e)
  105. }
  106. label { "One-time code"
  107. input { id = "code" name = "code" autocomplete = "one-time-code" inputmode = "numeric" pattern = "[0-9]{6}" maxlength = "6" required = "required" value = code on input(e) { emit setCode(e.target.value) } }
  108. }
  109. formButtons {
  110. button { id = "verify" type = "submit" "Sign in" }
  111. button { id = "back" type = "button" class = "quiet" "Other address" on click(e) { emit back(e) } }
  112. }
  113. }
  114. }
  115. if (signedIn) {
  116. accountBar {
  117. userEmail { id = "meemail" meEmail }
  118. button { id = "signout" type = "button" class = "quiet small" "Sign out" on click(e) { emit doSignOut(e) } }
  119. button { id = "signoutall" type = "button" class = "quiet small" "Sign out everywhere" on click(e) { emit doSignOutAll(e) } }
  120. }
  121. if (editing) {
  122. form { id = "identityform"
  123. on submit(e) {
  124. e.preventDefault()
  125. emit saveForm(e)
  126. }
  127. h1 { id = "formheading" editHeading }
  128. if (welcome) {
  129. p { id = "welcome" class = "lead" "Your account is ready and has a default identity. All of these fields are " strong { "optional" } " — fill in what you like, or skip it and do it later." }
  130. }
  131. label { "Identity name " fieldHint { "(shown when you pick an identity in an app; optional)" }
  132. input { id = "fidentityname" name = "identityName" maxlength = "60" autocomplete = "off" value = fIdentityName on input(e) { emit setF('identityName', e.target.value) } }
  133. }
  134. label { "Nickname " fieldHint { "(optional)" }
  135. input { id = "fnickname" name = "nickname" maxlength = "60" autocomplete = "nickname" value = fNickname on input(e) { emit setF('nickname', e.target.value) } }
  136. }
  137. label { "First name " fieldHint { "(optional)" }
  138. input { id = "ffirstname" name = "firstname" maxlength = "60" autocomplete = "given-name" value = fFirstname on input(e) { emit setF('firstname', e.target.value) } }
  139. }
  140. label { "Last name " fieldHint { "(optional)" }
  141. input { id = "flastname" name = "lastname" maxlength = "60" autocomplete = "family-name" value = fLastname on input(e) { emit setF('lastname', e.target.value) } }
  142. }
  143. avatarField {
  144. img { id = "avatarpreview" class = avatarPreviewClass src = fAvatar alt = "" width = "48" height = "48" }
  145. label { "Avatar " fieldHint { "(optional; a picture apps can show, e.g. next to your comments)" }
  146. input { id = "favatarfile" type = "file" accept = "image/png,image/jpeg,image/webp,image/gif" }
  147. }
  148. input { id = "favatar" name = "avatar" type = "hidden" value = fAvatar on input(e) { emit setF('avatar', e.target.value) } }
  149. button { id = "avatarremove" type = "button" class = avatarRemoveClass "Remove picture" on click(e) { emit setF('avatar', '') } }
  150. span { id = "avatarnote" class = "avatar-note" }
  151. }
  152. formButtons {
  153. button { id = "saveidentity" type = "submit" "Save" }
  154. if (welcome) { button { id = "skip" type = "button" class = "quiet" "Skip" on click(e) { emit closeForm(e) } } }
  155. if (notWelcome) { button { id = "cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeForm(e) } } }
  156. }
  157. }
  158. }
  159. if (choosing) {
  160. section { id = "choosesection"
  161. h1 { "Choose an identity" }
  162. p { class = "lead" "Which identity signs in to " strong { appName } "? The app gets only the identity's short id — no email, no names." }
  163. ul { id = "chooselist"
  164. for (row of identities) {
  165. li { class = "choice"
  166. if (row.hasAvatar) { img { class = "avatar" src = row.avatar alt = "" width = "32" height = "32" } }
  167. identityMain {
  168. identityLabel { row.label }
  169. identityShortId { class = "shortid" row.shortId }
  170. if (row.isDefault) { defaultBadge { "default" } }
  171. identityDetails { row.details }
  172. }
  173. button { type = "button" class = "choose" value = row.id "Continue" on click(e) { emit choose(e.target.value) } }
  174. }
  175. }
  176. }
  177. }
  178. }
  179. if (manage) {
  180. section { id = "identitiessection"
  181. sectionHead {
  182. h1 { "Your identities" }
  183. button { id = "newidentity" type = "button" class = "small" "New identity" on click(e) { emit openNew(e) } }
  184. }
  185. ul { id = "identities"
  186. for (row of identities) {
  187. li { class = "identity"
  188. if (row.hasAvatar) { img { class = "avatar" src = row.avatar alt = "" width = "32" height = "32" } }
  189. identityMain {
  190. identityLabel { row.label }
  191. identityShortId { class = "shortid" row.shortId }
  192. if (row.isDefault) { defaultBadge { "default" } }
  193. identityDetails { row.details }
  194. }
  195. identityActions {
  196. button { type = "button" class = "quiet small edit" value = row.id "Edit" on click(e) { emit openEdit(e.target.value) } }
  197. if (row.canDelete) { button { type = "button" class = "quiet small danger delete" value = row.id "Delete" on click(e) { emit remove(e.target.value) } } }
  198. }
  199. }
  200. }
  201. }
  202. }
  203. section { id = "timezonesection"
  204. h2 { "Time zone" }
  205. p { class = "lead" "Taken from your browser at your first login. Currently " strong { id = "timezone" timeZone } "." }
  206. form { id = "tzform"
  207. on submit(e) {
  208. e.preventDefault()
  209. emit saveZone(e)
  210. }
  211. label { "Time zone (IANA name, e.g. Europe/Vienna)"
  212. input { id = "tzinput" name = "timeZone" autocomplete = "off" maxlength = "64" required = "required" value = tzInput on input(e) { emit setTz(e.target.value) } }
  213. }
  214. formButtons {
  215. button { id = "savetz" type = "submit" "Save time zone" }
  216. button { id = "browsertz" type = "button" class = "quiet" "Use this browser's" on click(e) { emit useBrowserZone(e) } }
  217. }
  218. }
  219. }
  220. }
  221. }
  222. p { id = "notice" class = "notice" notice }
  223. p { id = "message" class = "message" message }
  224. }
  225. }
  226. on setEmail(v) { email = v }
  227. on setCode(v) { code = v }
  228. on setTz(v) { tzInput = v }
  229. on setF(name, v) {
  230. if (name == 'identityName') { fIdentityName = v }
  231. if (name == 'nickname') { fNickname = v }
  232. if (name == 'firstname') { fFirstname = v }
  233. if (name == 'lastname') { fLastname = v }
  234. if (name == 'avatar') { fAvatar = v hasAvatarPreview = v.trim() != '' }
  235. }
  236. // the browser's own time zone (IANA name) and whether a name is one it knows (`Intl` is a
  237. // browser global of client code, hybriel#18)
  238. browserZone = () => { return Intl.DateTimeFormat().resolvedOptions().timeZone }
  239. knownZone = (v) => { return v == 'UTC' || Intl.supportedValuesOf('timeZone').includes(v) }
  240. // THE CODE REQUEST is a plain POST to /api/code (project.hl), not a face: only an HTTP
  241. // request carries the client IP (X-Client-IP) the per-IP limit counts (mission 010).
  242. // A refusal (400, 429) answers { error } and is shown like every other message.
  243. on ask(e) {
  244. message = ''
  245. let res = fetch('/api/code', { method = 'POST' headers = { 'Content-Type' = 'application/json' } body = JSON.stringify({ email = email }) })
  246. let r = res != null ? res.json() : null
  247. if (r == null) {
  248. message = 'the server did not answer — try again'
  249. return null
  250. }
  251. if (r.error != null) {
  252. message = r.error
  253. return null
  254. }
  255. // THE SESSION REMEMBERS THE STEP, then the browser goes to the code page. A function
  256. // route gets no session (hybriel#11), so the face records it; it refuses unless a
  257. // code for the address is really waiting.
  258. let k = emit server rememberPending(r.email)
  259. if (k == null) {
  260. message = 'the server did not answer — try again'
  261. return null
  262. }
  263. if (k.error != null) {
  264. message = k.error
  265. return null
  266. }
  267. window.location.assign(codeUrl)
  268. }
  269. on verify(e) {
  270. message = ''
  271. let r = emit server verifyCode(sentTo, code, browserZone())
  272. if (r == null) {
  273. message = 'the server did not answer — try again'
  274. return null
  275. }
  276. if (r.error != null) {
  277. message = r.error
  278. return null
  279. }
  280. askCode = false
  281. // signed in: the address bar leaves the code page (a reload of /code would go back
  282. // to it anyway — project.hl codePage sends a signed-in browser there)
  283. window.history.replaceState(null, '', homeUrl)
  284. meEmail = r.account.email
  285. timeZone = r.account.timeZone
  286. tzInput = r.account.timeZone
  287. identities = r.identities
  288. signedIn = true
  289. notice = ''
  290. manage = !forApp
  291. choosing = forApp
  292. // THE FIRST LOGIN: the default identity's names, optional (for an app too: the
  293. // choice follows once the form is saved or skipped)
  294. if (r.created) {
  295. fillForm(r.identities[0])
  296. editHeading = 'Welcome — name your default identity'
  297. welcome = true
  298. notWelcome = false
  299. editing = true
  300. choosing = false
  301. }
  302. }
  303. // THE CHOICE: the server makes (or finds) this identity's id for the app and answers
  304. // the app's return URL with a one-time code — the browser goes there
  305. on choose(id) {
  306. message = ''
  307. notice = ''
  308. let r = emit server chooseIdentity(rid, '' + id)
  309. if (r == null) {
  310. message = 'the server did not answer — try again'
  311. return null
  312. }
  313. if (r.error != null) {
  314. message = r.error
  315. return null
  316. }
  317. notice = 'Back to ' + appName + ' …'
  318. window.location.assign(r.url)
  319. }
  320. fillForm = (row) => {
  321. editId = row.id
  322. fIdentityName = row.identityName
  323. fNickname = row.nickname
  324. fFirstname = row.firstname
  325. fLastname = row.lastname
  326. fAvatar = row.avatar
  327. hasAvatarPreview = row.hasAvatar
  328. return null
  329. }
  330. rowOf = (id) => {
  331. for (row of identities) { if ('' + row.id == '' + id) { return row } }
  332. return null
  333. }
  334. on openNew(e) {
  335. message = ''
  336. notice = ''
  337. editId = ''
  338. fIdentityName = ''
  339. fNickname = ''
  340. fFirstname = ''
  341. fLastname = ''
  342. fAvatar = ''
  343. hasAvatarPreview = false
  344. editHeading = 'New identity'
  345. welcome = false
  346. notWelcome = true
  347. editing = true
  348. }
  349. on openEdit(id) {
  350. message = ''
  351. notice = ''
  352. let row = rowOf(id)
  353. if (row == null) { return null }
  354. fillForm(row)
  355. editHeading = 'Edit ' + row.label
  356. welcome = false
  357. notWelcome = true
  358. editing = true
  359. }
  360. on closeForm(e) {
  361. editing = false
  362. welcome = false
  363. notWelcome = true
  364. message = ''
  365. choosing = forApp
  366. }
  367. on saveForm(e) {
  368. message = ''
  369. let fields = { identityName = fIdentityName nickname = fNickname firstname = fFirstname lastname = fLastname avatar = fAvatar }
  370. let r = null
  371. if (editId == '') {
  372. r = emit server addIdentity(fields)
  373. } else {
  374. r = emit server editIdentity('' + editId, fields)
  375. }
  376. if (r == null) {
  377. message = 'the server did not answer — try again'
  378. return null
  379. }
  380. if (r.error != null) {
  381. message = r.error
  382. return null
  383. }
  384. identities = r.identities
  385. notice = editId == '' ? 'Identity created.' : 'Identity saved.'
  386. editing = false
  387. welcome = false
  388. notWelcome = true
  389. choosing = forApp
  390. }
  391. on remove(id) {
  392. message = ''
  393. notice = ''
  394. let row = rowOf(id)
  395. if (row == null) { return null }
  396. if (!confirm('Delete the identity “' + row.label + '”?')) { return null }
  397. let r = emit server dropIdentity('' + id)
  398. if (r == null) {
  399. message = 'the server did not answer — try again'
  400. return null
  401. }
  402. if (r.error != null) {
  403. message = r.error
  404. return null
  405. }
  406. identities = r.identities
  407. if ('' + editId == '' + id) { editing = false }
  408. notice = 'Identity deleted.'
  409. }
  410. on useBrowserZone(e) { tzInput = browserZone() }
  411. on saveZone(e) {
  412. message = ''
  413. notice = ''
  414. let v = tzInput.trim()
  415. if (!knownZone(v)) {
  416. message = 'this browser does not know the time zone “' + v + '”'
  417. return null
  418. }
  419. let r = emit server changeTimeZone(v)
  420. if (r == null) {
  421. message = 'the server did not answer — try again'
  422. return null
  423. }
  424. if (r.error != null) {
  425. message = r.error
  426. return null
  427. }
  428. timeZone = r.timeZone
  429. tzInput = r.timeZone
  430. notice = 'Time zone saved.'
  431. }
  432. // "Other address": the session forgets the pending sign-in, back to the email form
  433. on back(e) {
  434. message = ''
  435. let r = emit server forgetPending()
  436. window.location.assign(homeUrl)
  437. }
  438. on doSignOut(e) {
  439. emit server signOut()
  440. signedIn = false
  441. choosing = false
  442. manage = false
  443. editing = false
  444. welcome = false
  445. notWelcome = true
  446. meEmail = ''
  447. identities = []
  448. askEmail = true
  449. message = ''
  450. notice = ''
  451. }
  452. // SIGNS OUT ON EVERY DEVICE at once (bumps the account's session epoch, store.hl
  453. // signOutEverywhere) — this browser included, so it resets to the sign-in form too.
  454. on doSignOutAll(e) {
  455. message = ''
  456. notice = ''
  457. if (!confirm('Sign out of ident on every device?')) { return null }
  458. let r = emit server signOutAll()
  459. if (r == null) {
  460. message = 'the server did not answer — try again'
  461. return null
  462. }
  463. if (r.error != null) {
  464. message = r.error
  465. return null
  466. }
  467. signedIn = false
  468. choosing = false
  469. manage = false
  470. editing = false
  471. welcome = false
  472. notWelcome = true
  473. meEmail = ''
  474. identities = []
  475. askEmail = true
  476. notice = 'Signed out on every device.'
  477. }
  478. // ---- the faces ------------------------------------------------------------------------
  479. // A face's LAST parameter is the session (the framework appends it). Every face that
  480. // needs the account asks accountOfSession, which only accepts the framework's Session.
  481. // A face runs on a BLANK instance: it has its arguments and its imports, not this
  482. // component's members or functions.
  483. // (the code request is NOT a face since mission 010: POST /api/code in project.hl — a face
  484. // sees no request headers, so it could not apply the per-IP limit; a face here would be a
  485. // way around it)
  486. // the right code signs the session in; the first one for an address creates the
  487. // account, its default identity and stores the browser's time zone
  488. on server verifyCode(email, code, timeZone, session) {
  489. if (session == null) { return { error = 'no session — reload the page' } }
  490. if (email == null || hlTypeName(email) != 'String' || code == null || hlTypeName(code) != 'String') { return { error = 'email and code must be strings' } }
  491. let r = checkCode(email, code, timeZone)
  492. if (r.error != null) { return { error = r.error } }
  493. beginSession(session, r.account.id)
  494. dropPending(session)
  495. return { account = r.account created = r.created identities = identityRows(r.account.id) }
  496. }
  497. on server addIdentity(fields, session) {
  498. let me = accountOfSession(session)
  499. if (me == null) { return { error = 'you are not signed in' } }
  500. let r = createIdentity(me.id, fields)
  501. if (r.error != null) { return r }
  502. return { identity = r.identity identities = identityRows(me.id) }
  503. }
  504. on server editIdentity(id, fields, session) {
  505. let me = accountOfSession(session)
  506. if (me == null) { return { error = 'you are not signed in' } }
  507. let r = updateIdentity(me.id, id, fields)
  508. if (r.error != null) { return r }
  509. return { identity = r.identity identities = identityRows(me.id) }
  510. }
  511. on server dropIdentity(id, session) {
  512. let me = accountOfSession(session)
  513. if (me == null) { return { error = 'you are not signed in' } }
  514. let r = deleteIdentity(me.id, id)
  515. if (r.error != null) { return r }
  516. return { deleted = r.deleted identities = identityRows(me.id) }
  517. }
  518. on server changeTimeZone(tz, session) {
  519. let me = accountOfSession(session)
  520. if (me == null) { return { error = 'you are not signed in' } }
  521. let r = setTimeZone(me.id, tz)
  522. if (r.error != null) { return r }
  523. return { timeZone = r.account.timeZone }
  524. }
  525. // the identity for the app's login request (apps.hl grantLogin): { url } or { error }
  526. on server chooseIdentity(rid, identity, session) {
  527. let me = accountOfSession(session)
  528. if (me == null) { return { error = 'you are not signed in' } }
  529. if (rid == null || hlTypeName(rid) != 'String') { return { error = 'field rid must be a string' } }
  530. if (identity == null || hlTypeName(identity) != 'String') { return { error = 'field identity must be a string' } }
  531. // a request that carries an invite (invites.hl openInvite) accepts the invite
  532. let rq = requestOf(rid)
  533. if (rq != null && rq.invite != '') { return grantInvite(me.id, rid, identity) }
  534. return grantLogin(me.id, rid, identity)
  535. }
  536. // THE PENDING SIGN-IN (ident#20): recorded after POST /api/code answered, only while a
  537. // code for that address is really waiting (store.hl recordPending); "Other address"
  538. // forgets it. Named apart from the store functions (faces dispatch by name, hybriel#36).
  539. on server rememberPending(email, session) {
  540. return recordPending(session, email)
  541. }
  542. on server forgetPending(session) {
  543. return dropPending(session)
  544. }
  545. on server signOut(session) {
  546. if (session != null) { session.user = null }
  547. return true
  548. }
  549. on server signOutAll(session) {
  550. let me = accountOfSession(session)
  551. if (me == null) { return { error = 'you are not signed in' } }
  552. // pushed BEFORE the sessions go: the audience reads each connection's session user
  553. emit client signedOutAll(me.id)
  554. return signOutEverywhere(me.id)
  555. }

Branches

Latest commits

  • 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