runlight 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/assets/build.json +4 -4
- data/assets/dashboard.css +1 -1
- data/assets/dashboard.js +12 -12
- data/assets/intl.json +1 -1
- data/assets/locales.json +5 -5
- data/assets/tracker.js +1 -1
- data/lib/runlight/accounts/accounts.rb +23 -15
- data/lib/runlight/accounts/crypto.rb +41 -13
- data/lib/runlight/accounts/pages.rb +102 -63
- data/lib/runlight/accounts/web.rb +55 -50
- data/lib/runlight/assistant.rb +9 -1
- data/lib/runlight/core.rb +44 -11
- data/lib/runlight/dates.rb +8 -0
- data/lib/runlight/http/fetcher.rb +12 -0
- data/lib/runlight/http/request.rb +3 -0
- data/lib/runlight/http/url.rb +6 -1
- data/lib/runlight/messages.rb +15 -1
- data/lib/runlight/oauth.rb +60 -41
- data/lib/runlight/routes.rb +47 -29
- data/lib/runlight/server/config.rb +1 -1
- data/lib/runlight/server/standalone.rb +3 -8
- data/lib/runlight/store/sql.rb +13 -0
- data/lib/runlight/store/sql_store.rb +187 -35
- data/lib/runlight/version.rb +1 -1
- data/lib/runlight/zip.rb +3 -1
- metadata +2 -2
data/assets/tracker.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
(function(){var
|
|
1
|
+
(function(){var a=window,n=document,u=navigator,p=n.currentScript;if(p){var o=function(e){return p.getAttribute("data-"+e)},R=p.src.replace(/s\.js(\?.*)?$/,"e"),k=o("site"),U=o("hash")!==null,I=o("manual")!==null,T=o("before-send"),$=function(e){return new RegExp("^"+e.replace(/[.+?^${}()|[\]\\]/g,"\\$&").replace(/\*/g,".*")+"$")},J=(o("exclude")||"").split(",").filter(Boolean).map(function(e){return $(e.trim())}),A=!1,H=a.runlight&&a.runlight.q||[],m="__RUNLIGHT_RULES__",O=typeof m=="object"&&(m[k]||m[location.hostname.replace(/^www\./,"")]||m["*"])||[],M=/[?&]runlight=pick\b/.test(location.search)&&/[?&]runlight_ticket=/.test(location.search);if(M){var N=n.createElement("script");N.src=p.src.replace(/s\.js(\?.*)?$/,"pick.js")+location.search,n.head.appendChild(N)}var z=!1;try{var B=/[?&]runlight=(ignore|track)\b/.exec(location.search);B&&(B[1]==="ignore"?localStorage.setItem("runlight_ignore","1"):localStorage.removeItem("runlight_ignore")),z=localStorage.getItem("runlight_ignore")==="1"}catch(e){}var P=M||u.webdriver||z||o("dnt")!==null&&(u.doNotTrack==="1"||a.doNotTrack==="1"),E=function(e,t){if(!(A||P)){var r={k:e,u:b};k&&(r.s=k);for(var i in t)r[i]=t[i];if(T!==null)try{if(r=a[T](r),!r||r.constructor!==Object)return}catch(y){return}var s=JSON.stringify(r);u.sendBeacon&&u.sendBeacon(R,s)||a.fetch&&fetch(R,{method:"POST",body:s,keepalive:!0}).catch(function(){})}},Y=function(){var e=new Uint8Array(6);return(a.crypto||{}).getRandomValues?crypto.getRandomValues(e):e.forEach(function(t,r){e[r]=Math.random()*256}),Array.from(e,function(t){return(t+256).toString(36).slice(-2)}).join("")},F=o("404")!==null,b="",v="",f=0,d=0,w=0,q=function(){return n.visibilityState==="visible"&&n.hasFocus()},D=function(){d&&(f+=Date.now()-d),d=0},c=function(){!d&&q()&&(d=Date.now())},S=function(){D(),v&&f>=1e3&&(E("engagement",{i:v,e:f,d:w}),f=0)},V=function(){var e=n.documentElement,t=Math.max(e.scrollHeight,n.body?n.body.scrollHeight:0),r=(a.scrollY||e.scrollTop)+a.innerHeight,i=t>0?Math.min(100,Math.round(r/t*100)):100;i>w&&(w=i)},K=function(){var e=location;return e.protocol+"//"+e.host+e.pathname+e.search+(U?e.hash:"")},h=function(e){var t=typeof e=="string"?new URL(e,location.href).href:K();if(t!==b){var r=b||n.referrer;S(),b=t;var i=new URL(t).pathname;A=J.some(function(s){return s.test(i)}),v=Y(),f=0,w=0,V(),c(),E("pageview",{i:v,r,t:n.title,w:screen.width,h:screen.height,l:u.language}),F&&l("404",{path:location.pathname}),F=!1}},l=function(e,t){typeof e!="string"||!e||E("event",{i:v,n:e,p:t&&typeof t=="object"?t:void 0})},C=function(e){var t=history[e];history[e]=function(){var r=t.apply(this,arguments);return setTimeout(h),r}};I||(C("pushState"),C("replaceState"),a.addEventListener("popstate",h),U&&a.addEventListener("hashchange",h)),n.addEventListener("visibilitychange",function(){n.visibilityState==="hidden"?S():c()}),a.addEventListener("pagehide",S),a.addEventListener("blur",D),a.addEventListener("focus",c),a.addEventListener("scroll",function(){V(),c()},{passive:!0}),n.addEventListener("pointerdown",c,!0),n.addEventListener("keydown",c,!0);var Q=/\.(pdf|zip|gz|tgz|dmg|pkg|exe|msi|apk|docx?|xlsx?|pptx?|csv|txt|rtf|epub|mp3|wav|mp4|mov|avi|mkv|psd|ai|svg|key|numbers|pages)$/i;n.addEventListener("click",function(e){var t=e.target;if(!(!t||!t.closest)){for(var r=t.closest("a[href]"),i=0;i<O.length;i++)try{var s=O[i],y=s[1];(s[0]==="s"?t.closest(y):r&&$(y).test(y.charAt(0)==="/"?r.pathname:r.href))&&l(s[2])}catch(W){}var g=t.closest("[data-runlight]");if(g){for(var G={},x=0;x<g.attributes.length;x++){var _=g.attributes[x];_.name.indexOf("data-runlight-")===0&&(G[_.name.slice(14)]=_.value)}l(g.getAttribute("data-runlight"),G)}if(!(!r||g)){var j=r.href;/^https?:/.test(j)&&(o("downloads")!=="false"&&Q.test(r.pathname)?l("File download",{url:j}):o("outbound")!=="false"&&r.host!==location.host&&l("Outbound link",{url:j}))}}},!0),a.runlight=l,l.pageview=h,I||h();for(var L=0;L<H.length;L++)l.apply(null,H[L])}})();
|
|
@@ -151,21 +151,9 @@ module Runlight
|
|
|
151
151
|
ticket("pending", user, now + (5 * 60_000))
|
|
152
152
|
end
|
|
153
153
|
|
|
154
|
-
#
|
|
155
|
-
# passes with it. A wrong password gets one once an account with
|
|
156
|
-
# two-factor has had too many, so the answer never tells a right password.
|
|
157
|
-
def decoy_for(user, now)
|
|
158
|
-
ticket("decoy", user, now + (5 * 60_000))
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# The account a code-step ticket names, and whether a right code may sign in with it:
|
|
162
|
-
# {"user" => user, "real" => bool}, or nil.
|
|
154
|
+
# The account a code-step ticket names, or nil.
|
|
163
155
|
def from_pending(value, now)
|
|
164
|
-
|
|
165
|
-
return { "user" => user, "real" => true } unless user.nil?
|
|
166
|
-
|
|
167
|
-
decoy = from_ticket("decoy", value, now)
|
|
168
|
-
decoy.nil? ? nil : { "user" => decoy, "real" => false }
|
|
156
|
+
from_ticket("pending", value, now)
|
|
169
157
|
end
|
|
170
158
|
|
|
171
159
|
# A ticket for a sign-in link sent by email, for fifteen minutes. A new password withdraws it.
|
|
@@ -249,6 +237,20 @@ module Runlight
|
|
|
249
237
|
nil
|
|
250
238
|
end
|
|
251
239
|
|
|
240
|
+
# Removes the accounts and invitations kept to a site that was deleted. A site made later is often given the
|
|
241
|
+
# same id, from its domain, and they must not find their way into it.
|
|
242
|
+
def forget_site(site)
|
|
243
|
+
init
|
|
244
|
+
turn do
|
|
245
|
+
list.select { |u| u["site"] == site }.each do |user|
|
|
246
|
+
@store.db.run("DELETE FROM rl_users WHERE id = ?", [user["id"]])
|
|
247
|
+
@store.set_setting("login-link-used:#{user["id"]}", nil)
|
|
248
|
+
end
|
|
249
|
+
@store.db.run("DELETE FROM rl_invites WHERE site = ?", [site])
|
|
250
|
+
end
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
252
254
|
# Removes an account. The owner cannot be removed.
|
|
253
255
|
def remove(id)
|
|
254
256
|
# The tables first: making them takes the same lock as a turn.
|
|
@@ -367,7 +369,13 @@ module Runlight
|
|
|
367
369
|
self.class.slot { Crypto.check_password(password, hash) }
|
|
368
370
|
return nil
|
|
369
371
|
end
|
|
370
|
-
self.class.slot { Crypto.check_password(password, user["hash"]) }
|
|
372
|
+
return nil unless self.class.slot { Crypto.check_password(password, user["hash"]) }
|
|
373
|
+
return user unless Crypto.weaker_than_now?(user["hash"])
|
|
374
|
+
|
|
375
|
+
# A hash from before the cost rose is made again now that the password is here, unless it changed meanwhile.
|
|
376
|
+
hash = self.class.slot { Crypto.hash_password(password) }
|
|
377
|
+
@store.db.run("UPDATE rl_users SET hash = ? WHERE id = ? AND hash = ?", [hash, user["id"], user["hash"]])
|
|
378
|
+
by_id(user["id"]) || user
|
|
371
379
|
end
|
|
372
380
|
|
|
373
381
|
# A cookie value naming the user and when it expires, signed with the
|
|
@@ -12,10 +12,12 @@ module Runlight
|
|
|
12
12
|
# TOTP, the otpauth address, recovery codes, and the signature on session cookies) are here too, as
|
|
13
13
|
# functions of their inputs alone, so the accounts class can call them.
|
|
14
14
|
module Crypto
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# What a new hash costs: one of OWASP's scrypt settings, 32 MiB a check. Each hash records its own cost,
|
|
16
|
+
# so this can rise again.
|
|
17
|
+
SCRYPT = { n: 32_768, r: 8, p: 3 }.freeze
|
|
18
|
+
# What a hash written as scrypt$salt$key costs: the setting before hashes recorded theirs.
|
|
19
|
+
SCRYPT_BEFORE = { n: 16_384, r: 8, p: 1 }.freeze
|
|
20
|
+
private_constant :SCRYPT, :SCRYPT_BEFORE
|
|
19
21
|
|
|
20
22
|
# As many PBKDF2 rounds as Cloudflare Workers allow, the strictest runtime Runlight runs on.
|
|
21
23
|
PBKDF2_ROUNDS = 100_000
|
|
@@ -71,10 +73,10 @@ module Runlight
|
|
|
71
73
|
same_bytes(a, b)
|
|
72
74
|
end
|
|
73
75
|
|
|
74
|
-
# A password hash,
|
|
76
|
+
# A password hash, scrypt$N$r$p$salt$key: it records its cost, which can rise later.
|
|
75
77
|
def hash_password(password)
|
|
76
78
|
salt = random_bytes(16)
|
|
77
|
-
"scrypt$#{base64url(salt)}$#{base64url(scrypt(password, salt, 32))}"
|
|
79
|
+
"scrypt$#{SCRYPT[:n]}$#{SCRYPT[:r]}$#{SCRYPT[:p]}$#{base64url(salt)}$#{base64url(scrypt(password, salt, 32, SCRYPT))}"
|
|
78
80
|
end
|
|
79
81
|
|
|
80
82
|
# Whether a password matches a hash, scrypt or PBKDF2. A stored key under MIN_KEY_BYTES is refused,
|
|
@@ -88,12 +90,15 @@ module Runlight
|
|
|
88
90
|
rescue ArgumentError
|
|
89
91
|
nil
|
|
90
92
|
end
|
|
91
|
-
if parts[0] == "scrypt" && parts.length
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
if parts[0] == "scrypt" && [3, 6].include?(parts.length)
|
|
94
|
+
cost = scrypt_cost(parts)
|
|
95
|
+
return false if cost.nil?
|
|
96
|
+
|
|
97
|
+
expected = decode.call(parts[-1])
|
|
98
|
+
salt = decode.call(parts[-2])
|
|
94
99
|
return false if expected.nil? || salt.nil? || expected.bytesize < MIN_KEY_BYTES
|
|
95
100
|
|
|
96
|
-
return same_bytes(scrypt(password, salt, expected.bytesize), expected)
|
|
101
|
+
return same_bytes(scrypt(password, salt, expected.bytesize, cost), expected)
|
|
97
102
|
end
|
|
98
103
|
if parts[0] == "pbkdf2" && parts.length == 4
|
|
99
104
|
rounds = Js.number(parts[1])
|
|
@@ -108,8 +113,31 @@ module Runlight
|
|
|
108
113
|
false
|
|
109
114
|
end
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
# Whether a stored hash costs less than a new one would, so the password it matched is hashed again: an
|
|
117
|
+
# scrypt hash with a lower cost, or a PBKDF2 one.
|
|
118
|
+
def weaker_than_now?(stored)
|
|
119
|
+
parts = stored.split("$", -1)
|
|
120
|
+
return true if parts[0] == "pbkdf2"
|
|
121
|
+
return false if parts[0] != "scrypt"
|
|
122
|
+
|
|
123
|
+
cost = scrypt_cost(parts)
|
|
124
|
+
!cost.nil? && cost[:n] * cost[:r] * cost[:p] < SCRYPT[:n] * SCRYPT[:r] * SCRYPT[:p]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A scrypt hash's cost: recorded in it (scrypt$N$r$p$salt$key), or the old setting (scrypt$salt$key).
|
|
128
|
+
# Nil for one out of bounds, so a stored hash cannot ask for a huge amount of memory or time.
|
|
129
|
+
def scrypt_cost(parts)
|
|
130
|
+
return SCRYPT_BEFORE if parts.length == 3
|
|
131
|
+
|
|
132
|
+
n, r, p = parts[1, 3].map { |part| Js.number(part) }
|
|
133
|
+
whole = ->(v, max) { v.is_a?(Integer) && v >= 1 && v <= max }
|
|
134
|
+
return nil unless whole.call(n, 1 << 20) && n >= 2 && (n & (n - 1)).zero? && whole.call(r, 32) && whole.call(p, 16)
|
|
135
|
+
|
|
136
|
+
{ n: n, r: r, p: p }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def scrypt(password, salt, length, cost)
|
|
140
|
+
length.zero? ? "".b : Scrypt.derive(password, salt, cost[:n], cost[:r], cost[:p], length)
|
|
113
141
|
end
|
|
114
142
|
|
|
115
143
|
def pbkdf2(password, salt, rounds, length)
|
|
@@ -251,7 +279,7 @@ module Runlight
|
|
|
251
279
|
base64url(hmac("SHA-256", secret, "#{body}.#{hash}"))
|
|
252
280
|
end
|
|
253
281
|
|
|
254
|
-
private_class_method :scrypt, :pbkdf2, :same_bytes, :seal_key, :decode_utf8
|
|
282
|
+
private_class_method :scrypt, :scrypt_cost, :pbkdf2, :same_bytes, :seal_key, :decode_utf8
|
|
255
283
|
end
|
|
256
284
|
end
|
|
257
285
|
end
|
|
@@ -54,7 +54,7 @@ module Runlight
|
|
|
54
54
|
CSS
|
|
55
55
|
|
|
56
56
|
# Applies the saved theme before the page paints, then runs the switch and Cmd+Shift+D (Ctrl+Shift+D).
|
|
57
|
-
|
|
57
|
+
AUTH_JS_SOURCE = <<~'JS'.chomp
|
|
58
58
|
(() => {
|
|
59
59
|
const COMMON = ["password", "passw0rd", "123456", "qwerty", "azerty", "letmein", "welcome", "admin", "runlight", "iloveyou", "abc123", "monkey", "dragon", "football", "baseball", "sunshine", "princess", "shadow", "master"];
|
|
60
60
|
function strength(password, avoid = []) {
|
|
@@ -157,11 +157,11 @@ module Runlight
|
|
|
157
157
|
})();
|
|
158
158
|
JS
|
|
159
159
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
private_constant :
|
|
160
|
+
THEME_ICONS = '<svg data-choice="light" viewBox="0 0 16 16" aria-hidden="true">' \
|
|
161
|
+
'<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/></svg>' \
|
|
162
|
+
'<svg data-choice="dark" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.5A5.5 5.5 0 0 1 6.5 2.5a5.5 5.5 0 1 0 7 7z"/></svg>' \
|
|
163
|
+
'<svg data-choice="system" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="3" width="12" height="8.5" rx="1.5"/><path d="M5.5 14h5M8 11.5V14"/></svg>'
|
|
164
|
+
private_constant :THEME_ICONS, :AUTH_JS_SOURCE
|
|
165
165
|
|
|
166
166
|
ESCAPES = { "&" => "&", "<" => "<", ">" => ">", '"' => """, "'" => "'" }.freeze
|
|
167
167
|
private_constant :ESCAPES
|
|
@@ -177,11 +177,31 @@ module Runlight
|
|
|
177
177
|
Version.build["icon"].to_s
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
# The script, with its words in a language the dashboard speaks. English is served as auth.js, the others as
|
|
181
|
+
# auth.js?lang=fr and so on.
|
|
182
|
+
def auth_js(lang = "en")
|
|
183
|
+
translator = Messages.translator(lang)
|
|
184
|
+
t = translator["t"]
|
|
185
|
+
q = ->(key) { Json.encode(t.call(key)) }
|
|
186
|
+
theme = translator["lang"] == "en" ? '"Theme: " + c' : "c === \"dark\" ? #{q.call("auth.themeDark")} : #{q.call("auth.themeLight")}"
|
|
187
|
+
strength = (0..4).map { |n| q.call("account.strength#{n}") }.join(", ")
|
|
188
|
+
AUTH_JS_SOURCE
|
|
189
|
+
.sub('button.title = c === "system" ? "Theme: device setting" : "Theme: " + c;',
|
|
190
|
+
"button.title = c === \"system\" ? #{q.call("auth.themeSystem")} : #{theme};")
|
|
191
|
+
.sub('eye.setAttribute("aria-label", shown ? "Hide password" : "Show password");',
|
|
192
|
+
"eye.setAttribute(\"aria-label\", shown ? #{q.call("auth.hidePassword")} : #{q.call("auth.showPassword")});")
|
|
193
|
+
.sub('eye.title = shown ? "Hide password" : "Show password";',
|
|
194
|
+
"eye.title = shown ? #{q.call("auth.hidePassword")} : #{q.call("auth.showPassword")};")
|
|
195
|
+
.sub('["Too short", "Weak", "Fair", "Good", "Strong"][score]', "[#{strength}][score]")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def page(base, lang, title, body)
|
|
181
199
|
b = esc(base)
|
|
200
|
+
translator = Messages.translator(lang)
|
|
201
|
+
code = translator["lang"]
|
|
182
202
|
<<~HTML
|
|
183
203
|
<!doctype html>
|
|
184
|
-
<html lang="
|
|
204
|
+
<html lang="#{code}">
|
|
185
205
|
<head>
|
|
186
206
|
<meta charset="utf-8">
|
|
187
207
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
@@ -189,14 +209,14 @@ module Runlight
|
|
|
189
209
|
<title>#{esc(title)} | Runlight</title>
|
|
190
210
|
<link rel="icon" href="#{icon}">
|
|
191
211
|
<link rel="stylesheet" href="#{b}/auth.css">
|
|
192
|
-
<script src="#{b}/auth.js"></script>
|
|
212
|
+
<script src="#{b}/auth.js#{code == "en" ? "" : "?lang=#{code}"}"></script>
|
|
193
213
|
</head>
|
|
194
214
|
<body>
|
|
195
215
|
<main>
|
|
196
216
|
<p class="brand">#{MARK}Runlight</p>
|
|
197
217
|
#{body}
|
|
198
218
|
</main>
|
|
199
|
-
#{
|
|
219
|
+
<button type="button" class="theme" aria-label="#{esc(translator["t"].call("auth.switchTheme"))}">#{THEME_ICONS}</button>
|
|
200
220
|
</body>
|
|
201
221
|
</html>
|
|
202
222
|
HTML
|
|
@@ -207,115 +227,134 @@ module Runlight
|
|
|
207
227
|
end
|
|
208
228
|
|
|
209
229
|
# opts: "error", "email", "next" (all optional), and "forgot".
|
|
210
|
-
def login_page(base, opts)
|
|
230
|
+
def login_page(base, opts, lang = "en")
|
|
211
231
|
b = esc(base)
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
232
|
+
translator = Messages.translator(lang)
|
|
233
|
+
t = translator["t"]
|
|
234
|
+
h = translator["html"]
|
|
235
|
+
link = "<a href=\"#{esc(opts["forgot"])}\" target=\"_blank\" rel=\"noopener\">#{h.call("auth.forgotLink")}</a>"
|
|
236
|
+
page(base, lang, t.call("auth.signIn"), <<~HTML.chomp)
|
|
237
|
+
<h1>#{h.call("auth.signIn")}</h1>
|
|
238
|
+
<p>#{h.call("auth.signInIntro")}</p>
|
|
215
239
|
#{error(opts["error"])}
|
|
216
240
|
<form method="post" action="#{b}/login">
|
|
217
241
|
<input type="hidden" name="next" value="#{esc(opts["next"] || "#{base}/")}">
|
|
218
|
-
<label
|
|
219
|
-
<label
|
|
220
|
-
<button type="submit"
|
|
242
|
+
<label>#{h.call("auth.email")}<input type="email" name="email" autocomplete="username" required autofocus value="#{esc(opts["email"] || "")}"></label>
|
|
243
|
+
<label>#{h.call("auth.password")}<input type="password" name="password" autocomplete="current-password" required></label>
|
|
244
|
+
<button type="submit">#{h.call("auth.signIn")}</button>
|
|
221
245
|
</form>
|
|
222
|
-
<p class="hint"
|
|
246
|
+
<p class="hint">#{h.call("auth.forgot", { "link" => link })}</p>
|
|
223
247
|
HTML
|
|
224
248
|
end
|
|
225
249
|
|
|
226
250
|
# The second step of signing in, for an account with two-factor on. opts: "pending", "next", and "error".
|
|
227
|
-
def code_page(base, opts)
|
|
251
|
+
def code_page(base, opts, lang = "en")
|
|
228
252
|
b = esc(base)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
253
|
+
translator = Messages.translator(lang)
|
|
254
|
+
h = translator["html"]
|
|
255
|
+
page(base, lang, translator["t"].call("auth.codeTitle"), <<~HTML.chomp)
|
|
256
|
+
<h1>#{h.call("auth.codeTitle")}</h1>
|
|
257
|
+
<p>#{h.call("auth.codeIntro")}</p>
|
|
232
258
|
#{error(opts["error"])}
|
|
233
259
|
<form method="post" action="#{b}/login/code">
|
|
234
260
|
<input type="hidden" name="pending" value="#{esc(opts["pending"])}">
|
|
235
261
|
<input type="hidden" name="next" value="#{esc(opts["next"])}">
|
|
236
|
-
<label
|
|
237
|
-
<button type="submit"
|
|
262
|
+
<label>#{h.call("auth.code")}<input type="text" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="12" required autofocus></label>
|
|
263
|
+
<button type="submit">#{h.call("auth.signIn")}</button>
|
|
238
264
|
</form>
|
|
239
|
-
<p class="hint"
|
|
265
|
+
<p class="hint">#{h.call("auth.codeHint")}</p>
|
|
240
266
|
HTML
|
|
241
267
|
end
|
|
242
268
|
|
|
243
269
|
# What someone invited with a role can do, after "as".
|
|
244
|
-
def role_text(role, site = nil)
|
|
245
|
-
|
|
246
|
-
return "
|
|
247
|
-
return "
|
|
270
|
+
def role_text(role, site = nil, lang = "en")
|
|
271
|
+
t = Messages.translator(lang)["t"]
|
|
272
|
+
return t.call(role == "viewer" ? "auth.roleViewerSite" : "auth.roleMemberSite", { "site" => site }) unless site.nil?
|
|
273
|
+
return t.call("auth.roleViewer") if role == "viewer"
|
|
274
|
+
return t.call("auth.roleMember") if role == "member"
|
|
248
275
|
|
|
249
|
-
"
|
|
276
|
+
t.call("auth.roleAdmin")
|
|
250
277
|
end
|
|
251
278
|
|
|
252
279
|
# Where an invited person chooses a password and joins. opts: "code", "email", "role", "host", and "error".
|
|
253
|
-
def invite_page(base, opts)
|
|
280
|
+
def invite_page(base, opts, lang = "en")
|
|
254
281
|
b = esc(base)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
282
|
+
translator = Messages.translator(lang)
|
|
283
|
+
h = translator["html"]
|
|
284
|
+
page(base, lang, translator["t"].call("auth.joinTitle"), <<~HTML.chomp)
|
|
285
|
+
<h1>#{h.call("auth.joinTitle")}</h1>
|
|
286
|
+
<p>#{h.call("auth.joinIntro", { "host" => esc(opts["host"]), "role" => esc(role_text(opts["role"], opts["site"], lang)) })}</p>
|
|
258
287
|
#{error(opts["error"])}
|
|
259
288
|
<form method="post" action="#{b}/invite">
|
|
260
289
|
<input type="hidden" name="code" value="#{esc(opts["code"])}">
|
|
261
|
-
<label
|
|
262
|
-
<label
|
|
263
|
-
<label
|
|
264
|
-
<button type="submit"
|
|
290
|
+
<label>#{h.call("auth.email")}<input type="email" value="#{esc(opts["email"])}" disabled></label>
|
|
291
|
+
<label>#{h.call("auth.password")}<input type="password" name="password" autocomplete="new-password" minlength="10" required autofocus></label>
|
|
292
|
+
<label>#{h.call("auth.passwordAgain")}<input type="password" name="again" autocomplete="new-password" minlength="10" required></label>
|
|
293
|
+
<button type="submit">#{h.call("auth.join")}</button>
|
|
265
294
|
</form>
|
|
266
|
-
<p class="hint"
|
|
295
|
+
<p class="hint">#{h.call("auth.passwordHint")}</p>
|
|
267
296
|
HTML
|
|
268
297
|
end
|
|
269
298
|
|
|
270
|
-
def invite_gone_page(base)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
299
|
+
def invite_gone_page(base, lang = "en")
|
|
300
|
+
translator = Messages.translator(lang)
|
|
301
|
+
h = translator["html"]
|
|
302
|
+
page(base, lang, translator["t"].call("auth.inviteGoneTitle"), <<~HTML.chomp)
|
|
303
|
+
<h1>#{h.call("auth.inviteGoneTitle")}</h1>
|
|
304
|
+
<p>#{h.call("auth.inviteGone")}</p>
|
|
274
305
|
HTML
|
|
275
306
|
end
|
|
276
307
|
|
|
277
308
|
# opts: "code", and optionally "error", "email", and "askCode".
|
|
278
|
-
def setup_page(base, opts)
|
|
309
|
+
def setup_page(base, opts, lang = "en")
|
|
279
310
|
b = esc(base)
|
|
311
|
+
translator = Messages.translator(lang)
|
|
312
|
+
h = translator["html"]
|
|
280
313
|
code = esc(opts["code"])
|
|
281
314
|
field = if Js.truthy?(opts["askCode"])
|
|
282
|
-
"<label
|
|
315
|
+
"<label>#{h.call("auth.setupToken")}<input type=\"password\" name=\"code\" autocomplete=\"off\" required value=\"#{code}\"></label>"
|
|
283
316
|
else
|
|
284
317
|
"<input type=\"hidden\" name=\"code\" value=\"#{code}\">"
|
|
285
318
|
end
|
|
286
|
-
page(base,
|
|
287
|
-
<h1
|
|
288
|
-
<p
|
|
319
|
+
page(base, lang, translator["t"].call("auth.setupTitle"), <<~HTML.chomp)
|
|
320
|
+
<h1>#{h.call("auth.setupTitle")}</h1>
|
|
321
|
+
<p>#{h.call("auth.setupIntro")}</p>
|
|
289
322
|
#{error(opts["error"])}
|
|
290
323
|
<form method="post" action="#{b}/setup">
|
|
291
324
|
#{field}
|
|
292
|
-
<label
|
|
293
|
-
<label
|
|
294
|
-
<label
|
|
295
|
-
<button type="submit"
|
|
325
|
+
<label>#{h.call("auth.email")}<input type="email" name="email" autocomplete="username" required autofocus value="#{esc(opts["email"] || "")}"></label>
|
|
326
|
+
<label>#{h.call("auth.password")}<input type="password" name="password" autocomplete="new-password" minlength="10" required></label>
|
|
327
|
+
<label>#{h.call("auth.passwordAgain")}<input type="password" name="again" autocomplete="new-password" minlength="10" required></label>
|
|
328
|
+
<button type="submit">#{h.call("auth.createAccount")}</button>
|
|
296
329
|
</form>
|
|
297
|
-
<p class="hint"
|
|
330
|
+
<p class="hint">#{h.call("auth.passwordHint")}</p>
|
|
298
331
|
HTML
|
|
299
332
|
end
|
|
300
333
|
|
|
301
|
-
# For a server with no account yet, which prints a setup link with a one-time code in its log.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
334
|
+
# For a server with no account yet, which prints a setup link with a one-time code in its log. `file` says the
|
|
335
|
+
# standalone server keeps it in setup.txt in its data folder instead.
|
|
336
|
+
def setup_locked_page(base, file = false, lang = "en")
|
|
337
|
+
translator = Messages.translator(lang)
|
|
338
|
+
h = translator["html"]
|
|
339
|
+
text = file ? h.call("auth.setupLockedFile", { "command" => "<code>runlight setup</code>" }) : h.call("auth.setupLocked")
|
|
340
|
+
page(base, lang, translator["t"].call("auth.finishTitle"), <<~HTML.chomp)
|
|
341
|
+
<h1>#{h.call("auth.finishTitle")}</h1>
|
|
342
|
+
<p>#{text}</p>
|
|
307
343
|
HTML
|
|
308
344
|
end
|
|
309
345
|
|
|
310
346
|
# For an app with accounts on but nothing to prove the first account with.
|
|
311
|
-
def setup_needs_token_page(base)
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
347
|
+
def setup_needs_token_page(base, lang = "en")
|
|
348
|
+
translator = Messages.translator(lang)
|
|
349
|
+
h = translator["html"]
|
|
350
|
+
page(base, lang, translator["t"].call("auth.finishTitle"), <<~HTML.chomp)
|
|
351
|
+
<h1>#{h.call("auth.finishTitle")}</h1>
|
|
352
|
+
<p>#{h.call("auth.setupNeedsToken")}</p>
|
|
315
353
|
HTML
|
|
316
354
|
end
|
|
317
355
|
|
|
318
356
|
private_class_method :esc, :icon, :page, :error
|
|
357
|
+
AUTH_JS = auth_js("en")
|
|
319
358
|
end
|
|
320
359
|
end
|
|
321
360
|
end
|