ident
All repositories: gitoria
21.1 KB
// components/home.hl — `/` and `/signin/:rid`: ALL OF IDENT (pieces 1+2, tickets #24 #25;// CONCEPT.md).// Signed out: email → a six-digit code by mail → signed in. There is no registration:// the first right code for an address creates the account with a DEFAULT IDENTITY, then// this page shows that identity's name fields and says they are optional (Skip).// Signed in: the account's identities (list, new, edit, delete — never the last one)// and its time zone (the browser's at the first login; changeable here).//// Under `/signin/<rid>` the page belongs to an APP'S LOGIN BUTTON (apps.hl requestOf):// it names the app, signs in to ident if needed, then asks WHICH IDENTITY the app gets// (with a single identity it still shows which one, one click) and sends the browser back// to the app with a one-time code. Managing identities stays on `/`.//// Every step takes the server's answer from the CALL (the value form of emit): it rides// the ack, over the socket or the POST fallback.parent './main.hl'import { checkCode, accountOfSession, recordPending, pendingOf, dropPending, identityRows, createIdentity, updateIdentity, deleteIdentity, setTimeZone, beginSession, signOutEverywhere } from '../store.hl'import { requestOf, grantLogin } from '../apps.hl'import { grantInvite } from '../invites.hl'import { siteName } from '../project.hl'session = nullrid = null// 'code' on THE CODE PAGE `/code` and `/signin/<rid>/code` (ident#20; project.hl codePage// renders this component there, only while the session has a pending code), else nullstep = nullonCodePage = step == 'code'// where this login lives: the email form, and the code page after "Send me a code"homeUrl = rid != null ? '/signin/' + rid : '/'codeUrl = rid != null ? '/signin/' + rid + '/code' : '/code'me = accountOfSession(session)signedIn = me != null// THE APP'S LOGIN REQUEST (null on `/`, or when unknown / expired)request = rid != null ? requestOf(rid) : nullforApp = request != nullbadRequest = rid != null && request == nullappName = request != null ? request.app.name : ''appOrigin = request != null ? request.origin : ''forInvite = request != null && request.invite != ''requestLead = forInvite ? 'You are invited to ' : 'Sign in to 'choosing = forApp && me != null // the identity choice for the appmanage = rid == null && me != null // identities + time zone (only on `/`)meEmail = me != null ? me.email : ''timeZone = me != null ? me.timeZone : ''tzInput = timeZoneidentities = me != null ? identityRows(me.id) : []__title = siteName + (forApp ? ' | sign in to ' + appName : (me != null ? ' | your identities' : ' | sign in'))// the login steps (a View's `if` takes a member, so each step is one).// THE CODE STEP IS ITS OWN PAGE (ident#20, creator: "just make a /code where it checks a// pending code"): "Send me a code" records the address in this browser's session (face// rememberPending) and goes to `/code`; that page shows the code form for the session's// pending address (store.hl pendingOf) — so a reload, a second tab or a re-seed keeps it.pendingEmail = onCodePage && me == null && !badRequest ? pendingOf(session) : nullaskEmail = me == null && !badRequest && !onCodePageaskCode = pendingEmail != nullemail = ''sentTo = pendingEmail != null ? pendingEmail : ''code = ''message = ''notice = ''// THE IDENTITY FORM — new, edit, and right after the first login the default identity// ("welcome": the names are optional, Skip closes it)editing = falsewelcome = falsenotWelcome = true // `if (!welcome)` in a View renders nothing: an `if` takes a membereditId = '' // '' = a new identity, else the identity's id (a UUID)editHeading = ''fIdentityName = ''fNickname = ''fFirstname = ''fLastname = ''fAvatar = ''hasAvatarPreview = falseavatarPreviewClass = hasAvatarPreview ? 'avatar' : 'avatar hidden'avatarRemoveClass = hasAvatarPreview ? 'quiet small' : 'quiet small hidden'View {identCard {if (forApp) {appRequest { id = "apprequest" requestLead strong { id = "appname" appName } " " requestOrigin { id = "apporigin" appOrigin } }}if (badRequest) {h1 { "Login request expired" }p { id = "badrequest" class = "message" "This login request is unknown or expired. Go back to the app and start the login again." }}if (askEmail) {h1 { "Sign in" }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." }form { id = "emailform"on submit(e) {e.preventDefault()emit ask(e)}label { "Email"input { id = "email" name = "email" type = "email" autocomplete = "email" required = "required" value = email on input(e) { emit setEmail(e.target.value) } }}button { id = "sendcode" type = "submit" "Send me a code" }}}if (askCode) {h1 { "Enter your code" }p { id = "sentto" class = "lead" "We sent a six-digit code to " strong { sentTo } ". It is valid for 10 minutes." }form { id = "codeform"on submit(e) {e.preventDefault()emit verify(e)}label { "One-time code"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) } }}formButtons {button { id = "verify" type = "submit" "Sign in" }button { id = "back" type = "button" class = "quiet" "Other address" on click(e) { emit back(e) } }}}}if (signedIn) {accountBar {userEmail { id = "meemail" meEmail }button { id = "signout" type = "button" class = "quiet small" "Sign out" on click(e) { emit doSignOut(e) } }button { id = "signoutall" type = "button" class = "quiet small" "Sign out everywhere" on click(e) { emit doSignOutAll(e) } }}if (editing) {form { id = "identityform"on submit(e) {e.preventDefault()emit saveForm(e)}h1 { id = "formheading" editHeading }if (welcome) {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." }}label { "Identity name " fieldHint { "(shown when you pick an identity in an app; optional)" }input { id = "fidentityname" name = "identityName" maxlength = "60" autocomplete = "off" value = fIdentityName on input(e) { emit setF('identityName', e.target.value) } }}label { "Nickname " fieldHint { "(optional)" }input { id = "fnickname" name = "nickname" maxlength = "60" autocomplete = "nickname" value = fNickname on input(e) { emit setF('nickname', e.target.value) } }}label { "First name " fieldHint { "(optional)" }input { id = "ffirstname" name = "firstname" maxlength = "60" autocomplete = "given-name" value = fFirstname on input(e) { emit setF('firstname', e.target.value) } }}label { "Last name " fieldHint { "(optional)" }input { id = "flastname" name = "lastname" maxlength = "60" autocomplete = "family-name" value = fLastname on input(e) { emit setF('lastname', e.target.value) } }}avatarField {img { id = "avatarpreview" class = avatarPreviewClass src = fAvatar alt = "" width = "48" height = "48" }label { "Avatar " fieldHint { "(optional; a picture apps can show, e.g. next to your comments)" }input { id = "favatarfile" type = "file" accept = "image/png,image/jpeg,image/webp,image/gif" }}input { id = "favatar" name = "avatar" type = "hidden" value = fAvatar on input(e) { emit setF('avatar', e.target.value) } }button { id = "avatarremove" type = "button" class = avatarRemoveClass "Remove picture" on click(e) { emit setF('avatar', '') } }span { id = "avatarnote" class = "avatar-note" }}formButtons {button { id = "saveidentity" type = "submit" "Save" }if (welcome) { button { id = "skip" type = "button" class = "quiet" "Skip" on click(e) { emit closeForm(e) } } }if (notWelcome) { button { id = "cancel" type = "button" class = "quiet" "Cancel" on click(e) { emit closeForm(e) } } }}}}if (choosing) {section { id = "choosesection"h1 { "Choose an identity" }p { class = "lead" "Which identity signs in to " strong { appName } "? The app gets only the identity's short id — no email, no names." }ul { id = "chooselist"for (row of identities) {li { class = "choice"if (row.hasAvatar) { img { class = "avatar" src = row.avatar alt = "" width = "32" height = "32" } }identityMain {identityLabel { row.label }identityShortId { class = "shortid" row.shortId }if (row.isDefault) { defaultBadge { "default" } }identityDetails { row.details }}button { type = "button" class = "choose" value = row.id "Continue" on click(e) { emit choose(e.target.value) } }}}}}}if (manage) {section { id = "identitiessection"sectionHead {h1 { "Your identities" }button { id = "newidentity" type = "button" class = "small" "New identity" on click(e) { emit openNew(e) } }}ul { id = "identities"for (row of identities) {li { class = "identity"if (row.hasAvatar) { img { class = "avatar" src = row.avatar alt = "" width = "32" height = "32" } }identityMain {identityLabel { row.label }identityShortId { class = "shortid" row.shortId }if (row.isDefault) { defaultBadge { "default" } }identityDetails { row.details }}identityActions {button { type = "button" class = "quiet small edit" value = row.id "Edit" on click(e) { emit openEdit(e.target.value) } }if (row.canDelete) { button { type = "button" class = "quiet small danger delete" value = row.id "Delete" on click(e) { emit remove(e.target.value) } } }}}}}}section { id = "timezonesection"h2 { "Time zone" }p { class = "lead" "Taken from your browser at your first login. Currently " strong { id = "timezone" timeZone } "." }form { id = "tzform"on submit(e) {e.preventDefault()emit saveZone(e)}label { "Time zone (IANA name, e.g. Europe/Vienna)"input { id = "tzinput" name = "timeZone" autocomplete = "off" maxlength = "64" required = "required" value = tzInput on input(e) { emit setTz(e.target.value) } }}formButtons {button { id = "savetz" type = "submit" "Save time zone" }button { id = "browsertz" type = "button" class = "quiet" "Use this browser's" on click(e) { emit useBrowserZone(e) } }}}}}}p { id = "notice" class = "notice" notice }p { id = "message" class = "message" message }}}on setEmail(v) { email = v }on setCode(v) { code = v }on setTz(v) { tzInput = v }on setF(name, v) {if (name == 'identityName') { fIdentityName = v }if (name == 'nickname') { fNickname = v }if (name == 'firstname') { fFirstname = v }if (name == 'lastname') { fLastname = v }if (name == 'avatar') { fAvatar = v hasAvatarPreview = v.trim() != '' }}// the browser's own time zone (IANA name) and whether a name is one it knows (`Intl` is a// browser global of client code, hybriel#18)browserZone = () => { return Intl.DateTimeFormat().resolvedOptions().timeZone }knownZone = (v) => { return v == 'UTC' || Intl.supportedValuesOf('timeZone').includes(v) }// THE CODE REQUEST is a plain POST to /api/code (project.hl), not a face: only an HTTP// request carries the client IP (X-Client-IP) the per-IP limit counts (mission 010).// A refusal (400, 429) answers { error } and is shown like every other message.on ask(e) {message = ''let res = fetch('/api/code', { method = 'POST' headers = { 'Content-Type' = 'application/json' } body = JSON.stringify({ email = email }) })let r = res != null ? res.json() : nullif (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}// THE SESSION REMEMBERS THE STEP, then the browser goes to the code page. A function// route gets no session (hybriel#11), so the face records it; it refuses unless a// code for the address is really waiting.let k = emit server rememberPending(r.email)if (k == null) {message = 'the server did not answer — try again'return null}if (k.error != null) {message = k.errorreturn null}window.location.assign(codeUrl)}on verify(e) {message = ''let r = emit server verifyCode(sentTo, code, browserZone())if (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}askCode = false// signed in: the address bar leaves the code page (a reload of /code would go back// to it anyway — project.hl codePage sends a signed-in browser there)window.history.replaceState(null, '', homeUrl)meEmail = r.account.emailtimeZone = r.account.timeZonetzInput = r.account.timeZoneidentities = r.identitiessignedIn = truenotice = ''manage = !forAppchoosing = forApp// THE FIRST LOGIN: the default identity's names, optional (for an app too: the// choice follows once the form is saved or skipped)if (r.created) {fillForm(r.identities[0])editHeading = 'Welcome — name your default identity'welcome = truenotWelcome = falseediting = truechoosing = false}}// THE CHOICE: the server makes (or finds) this identity's id for the app and answers// the app's return URL with a one-time code — the browser goes thereon choose(id) {message = ''notice = ''let r = emit server chooseIdentity(rid, '' + id)if (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}notice = 'Back to ' + appName + ' …'window.location.assign(r.url)}fillForm = (row) => {editId = row.idfIdentityName = row.identityNamefNickname = row.nicknamefFirstname = row.firstnamefLastname = row.lastnamefAvatar = row.avatarhasAvatarPreview = row.hasAvatarreturn null}rowOf = (id) => {for (row of identities) { if ('' + row.id == '' + id) { return row } }return null}on openNew(e) {message = ''notice = ''editId = ''fIdentityName = ''fNickname = ''fFirstname = ''fLastname = ''fAvatar = ''hasAvatarPreview = falseeditHeading = 'New identity'welcome = falsenotWelcome = trueediting = true}on openEdit(id) {message = ''notice = ''let row = rowOf(id)if (row == null) { return null }fillForm(row)editHeading = 'Edit ' + row.labelwelcome = falsenotWelcome = trueediting = true}on closeForm(e) {editing = falsewelcome = falsenotWelcome = truemessage = ''choosing = forApp}on saveForm(e) {message = ''let fields = { identityName = fIdentityName nickname = fNickname firstname = fFirstname lastname = fLastname avatar = fAvatar }let r = nullif (editId == '') {r = emit server addIdentity(fields)} else {r = emit server editIdentity('' + editId, fields)}if (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}identities = r.identitiesnotice = editId == '' ? 'Identity created.' : 'Identity saved.'editing = falsewelcome = falsenotWelcome = truechoosing = forApp}on remove(id) {message = ''notice = ''let row = rowOf(id)if (row == null) { return null }if (!confirm('Delete the identity “' + row.label + '”?')) { return null }let r = emit server dropIdentity('' + id)if (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}identities = r.identitiesif ('' + editId == '' + id) { editing = false }notice = 'Identity deleted.'}on useBrowserZone(e) { tzInput = browserZone() }on saveZone(e) {message = ''notice = ''let v = tzInput.trim()if (!knownZone(v)) {message = 'this browser does not know the time zone “' + v + '”'return null}let r = emit server changeTimeZone(v)if (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}timeZone = r.timeZonetzInput = r.timeZonenotice = 'Time zone saved.'}// "Other address": the session forgets the pending sign-in, back to the email formon back(e) {message = ''let r = emit server forgetPending()window.location.assign(homeUrl)}on doSignOut(e) {emit server signOut()signedIn = falsechoosing = falsemanage = falseediting = falsewelcome = falsenotWelcome = truemeEmail = ''identities = []askEmail = truemessage = ''notice = ''}// SIGNS OUT ON EVERY DEVICE at once (bumps the account's session epoch, store.hl// signOutEverywhere) — this browser included, so it resets to the sign-in form too.on doSignOutAll(e) {message = ''notice = ''if (!confirm('Sign out of ident on every device?')) { return null }let r = emit server signOutAll()if (r == null) {message = 'the server did not answer — try again'return null}if (r.error != null) {message = r.errorreturn null}signedIn = falsechoosing = falsemanage = falseediting = falsewelcome = falsenotWelcome = truemeEmail = ''identities = []askEmail = truenotice = 'Signed out on every device.'}// ---- the faces ------------------------------------------------------------------------// A face's LAST parameter is the session (the framework appends it). Every face that// needs the account asks accountOfSession, which only accepts the framework's Session.// A face runs on a BLANK instance: it has its arguments and its imports, not this// component's members or functions.// (the code request is NOT a face since mission 010: POST /api/code in project.hl — a face// sees no request headers, so it could not apply the per-IP limit; a face here would be a// way around it)// the right code signs the session in; the first one for an address creates the// account, its default identity and stores the browser's time zoneon server verifyCode(email, code, timeZone, session) {if (session == null) { return { error = 'no session — reload the page' } }if (email == null || hlTypeName(email) != 'String' || code == null || hlTypeName(code) != 'String') { return { error = 'email and code must be strings' } }let r = checkCode(email, code, timeZone)if (r.error != null) { return { error = r.error } }beginSession(session, r.account.id)dropPending(session)return { account = r.account created = r.created identities = identityRows(r.account.id) }}on server addIdentity(fields, session) {let me = accountOfSession(session)if (me == null) { return { error = 'you are not signed in' } }let r = createIdentity(me.id, fields)if (r.error != null) { return r }return { identity = r.identity identities = identityRows(me.id) }}on server editIdentity(id, fields, session) {let me = accountOfSession(session)if (me == null) { return { error = 'you are not signed in' } }let r = updateIdentity(me.id, id, fields)if (r.error != null) { return r }return { identity = r.identity identities = identityRows(me.id) }}on server dropIdentity(id, session) {let me = accountOfSession(session)if (me == null) { return { error = 'you are not signed in' } }let r = deleteIdentity(me.id, id)if (r.error != null) { return r }return { deleted = r.deleted identities = identityRows(me.id) }}on server changeTimeZone(tz, session) {let me = accountOfSession(session)if (me == null) { return { error = 'you are not signed in' } }let r = setTimeZone(me.id, tz)if (r.error != null) { return r }return { timeZone = r.account.timeZone }}// the identity for the app's login request (apps.hl grantLogin): { url } or { error }on server chooseIdentity(rid, identity, session) {let me = accountOfSession(session)if (me == null) { return { error = 'you are not signed in' } }if (rid == null || hlTypeName(rid) != 'String') { return { error = 'field rid must be a string' } }if (identity == null || hlTypeName(identity) != 'String') { return { error = 'field identity must be a string' } }// a request that carries an invite (invites.hl openInvite) accepts the invitelet rq = requestOf(rid)if (rq != null && rq.invite != '') { return grantInvite(me.id, rid, identity) }return grantLogin(me.id, rid, identity)}// THE PENDING SIGN-IN (ident#20): recorded after POST /api/code answered, only while a// code for that address is really waiting (store.hl recordPending); "Other address"// forgets it. Named apart from the store functions (faces dispatch by name, hybriel#36).on server rememberPending(email, session) {return recordPending(session, email)}on server forgetPending(session) {return dropPending(session)}on server signOut(session) {if (session != null) { session.user = null }return true}on server signOutAll(session) {let me = accountOfSession(session)if (me == null) { return { error = 'you are not signed in' } }// pushed BEFORE the sessions go: the audience reads each connection's session useremit client signedOutAll(me.id)return signOutEverywhere(me.id)}
Branches
- mainmain branch
Latest commits
- 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