regstack 0.1.0__py3-none-any.whl
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.
- regstack/__init__.py +5 -0
- regstack/app.py +150 -0
- regstack/auth/__init__.py +21 -0
- regstack/auth/clock.py +29 -0
- regstack/auth/dependencies.py +102 -0
- regstack/auth/jwt.py +145 -0
- regstack/auth/lockout.py +59 -0
- regstack/auth/mfa.py +29 -0
- regstack/auth/password.py +20 -0
- regstack/auth/tokens.py +19 -0
- regstack/cli/__init__.py +0 -0
- regstack/cli/__main__.py +27 -0
- regstack/cli/_runtime.py +39 -0
- regstack/cli/admin.py +45 -0
- regstack/cli/doctor.py +186 -0
- regstack/cli/init.py +236 -0
- regstack/config/__init__.py +4 -0
- regstack/config/loader.py +114 -0
- regstack/config/schema.py +148 -0
- regstack/config/secrets.py +22 -0
- regstack/db/__init__.py +17 -0
- regstack/db/client.py +26 -0
- regstack/db/indexes.py +70 -0
- regstack/db/repositories/__init__.py +0 -0
- regstack/db/repositories/blacklist_repo.py +28 -0
- regstack/db/repositories/login_attempt_repo.py +27 -0
- regstack/db/repositories/mfa_code_repo.py +99 -0
- regstack/db/repositories/pending_repo.py +76 -0
- regstack/db/repositories/user_repo.py +169 -0
- regstack/email/__init__.py +12 -0
- regstack/email/base.py +23 -0
- regstack/email/composer.py +142 -0
- regstack/email/console.py +28 -0
- regstack/email/factory.py +23 -0
- regstack/email/ses.py +47 -0
- regstack/email/smtp.py +46 -0
- regstack/email/templates/email_change.html +15 -0
- regstack/email/templates/email_change.subject.txt +1 -0
- regstack/email/templates/email_change.txt +7 -0
- regstack/email/templates/password_reset.html +15 -0
- regstack/email/templates/password_reset.subject.txt +1 -0
- regstack/email/templates/password_reset.txt +7 -0
- regstack/email/templates/sms_login_mfa.txt +1 -0
- regstack/email/templates/sms_phone_setup.txt +1 -0
- regstack/email/templates/verification.html +15 -0
- regstack/email/templates/verification.subject.txt +1 -0
- regstack/email/templates/verification.txt +7 -0
- regstack/hooks/__init__.py +3 -0
- regstack/hooks/events.py +59 -0
- regstack/models/__init__.py +15 -0
- regstack/models/_objectid.py +30 -0
- regstack/models/login_attempt.py +31 -0
- regstack/models/mfa_code.py +40 -0
- regstack/models/pending_registration.py +38 -0
- regstack/models/user.py +104 -0
- regstack/routers/__init__.py +37 -0
- regstack/routers/_schemas.py +34 -0
- regstack/routers/account.py +274 -0
- regstack/routers/admin.py +187 -0
- regstack/routers/login.py +223 -0
- regstack/routers/logout.py +39 -0
- regstack/routers/password.py +114 -0
- regstack/routers/phone.py +242 -0
- regstack/routers/register.py +99 -0
- regstack/routers/verify.py +116 -0
- regstack/sms/__init__.py +5 -0
- regstack/sms/base.py +24 -0
- regstack/sms/factory.py +23 -0
- regstack/sms/null.py +26 -0
- regstack/sms/sns.py +42 -0
- regstack/sms/twilio.py +49 -0
- regstack/ui/__init__.py +3 -0
- regstack/ui/pages.py +148 -0
- regstack/ui/static/css/core.css +204 -0
- regstack/ui/static/css/theme.css +43 -0
- regstack/ui/static/js/regstack.js +411 -0
- regstack/ui/templates/auth/email_change_confirm.html +10 -0
- regstack/ui/templates/auth/forgot.html +14 -0
- regstack/ui/templates/auth/login.html +24 -0
- regstack/ui/templates/auth/me.html +110 -0
- regstack/ui/templates/auth/mfa_confirm.html +14 -0
- regstack/ui/templates/auth/register.html +23 -0
- regstack/ui/templates/auth/reset.html +13 -0
- regstack/ui/templates/auth/verify.html +10 -0
- regstack/ui/templates/base.html +46 -0
- regstack/version.py +1 -0
- regstack-0.1.0.dist-info/METADATA +209 -0
- regstack-0.1.0.dist-info/RECORD +92 -0
- regstack-0.1.0.dist-info/WHEEL +4 -0
- regstack-0.1.0.dist-info/entry_points.txt +2 -0
- regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
- regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/* regstack — structural CSS. Colors and fonts are defined entirely via
|
|
2
|
+
CSS custom properties in theme.css so this file can ship unchanged
|
|
3
|
+
regardless of host branding. */
|
|
4
|
+
|
|
5
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
6
|
+
|
|
7
|
+
html { -webkit-text-size-adjust: 100%; }
|
|
8
|
+
|
|
9
|
+
body {
|
|
10
|
+
margin: 0;
|
|
11
|
+
min-height: 100vh;
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
background: var(--rs-bg);
|
|
15
|
+
color: var(--rs-fg);
|
|
16
|
+
font-family: var(--rs-font-body);
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
font-size: 16px;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
a { color: var(--rs-accent); text-decoration: none; }
|
|
22
|
+
a:hover, a:focus-visible { text-decoration: underline; }
|
|
23
|
+
|
|
24
|
+
img { max-width: 100%; height: auto; }
|
|
25
|
+
|
|
26
|
+
.rs-header {
|
|
27
|
+
padding: 16px 24px;
|
|
28
|
+
border-bottom: 1px solid var(--rs-border);
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 16px;
|
|
32
|
+
background: var(--rs-surface);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.rs-brand {
|
|
36
|
+
display: inline-flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
gap: 10px;
|
|
39
|
+
font-family: var(--rs-font-display);
|
|
40
|
+
font-size: 20px;
|
|
41
|
+
font-weight: 600;
|
|
42
|
+
color: var(--rs-fg);
|
|
43
|
+
}
|
|
44
|
+
.rs-brand:hover { text-decoration: none; }
|
|
45
|
+
.rs-brand-logo { height: 28px; width: auto; }
|
|
46
|
+
.rs-brand-tagline { color: var(--rs-fg-muted); font-size: 14px; margin-left: auto; }
|
|
47
|
+
|
|
48
|
+
.rs-main {
|
|
49
|
+
flex: 1;
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: flex-start;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
padding: 32px 16px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.rs-card {
|
|
57
|
+
width: 100%;
|
|
58
|
+
max-width: 420px;
|
|
59
|
+
background: var(--rs-surface);
|
|
60
|
+
border: 1px solid var(--rs-border);
|
|
61
|
+
border-radius: var(--rs-radius);
|
|
62
|
+
padding: 28px 28px 32px;
|
|
63
|
+
box-shadow: var(--rs-shadow);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.rs-title {
|
|
67
|
+
font-family: var(--rs-font-display);
|
|
68
|
+
font-size: 24px;
|
|
69
|
+
margin: 0 0 18px;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.rs-meta {
|
|
74
|
+
margin: 14px 0 0;
|
|
75
|
+
color: var(--rs-fg-muted);
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.rs-form {
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
gap: 14px;
|
|
83
|
+
margin: 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.rs-field {
|
|
87
|
+
display: flex;
|
|
88
|
+
flex-direction: column;
|
|
89
|
+
gap: 6px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.rs-label {
|
|
93
|
+
font-size: 13px;
|
|
94
|
+
font-weight: 500;
|
|
95
|
+
color: var(--rs-fg-muted);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
input[type="email"],
|
|
99
|
+
input[type="text"],
|
|
100
|
+
input[type="password"] {
|
|
101
|
+
width: 100%;
|
|
102
|
+
padding: 10px 12px;
|
|
103
|
+
border: 1px solid var(--rs-border);
|
|
104
|
+
border-radius: var(--rs-radius);
|
|
105
|
+
background: var(--rs-bg);
|
|
106
|
+
color: var(--rs-fg);
|
|
107
|
+
font: inherit;
|
|
108
|
+
}
|
|
109
|
+
input:focus-visible {
|
|
110
|
+
outline: 2px solid var(--rs-accent);
|
|
111
|
+
outline-offset: 1px;
|
|
112
|
+
border-color: transparent;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.rs-btn {
|
|
116
|
+
display: inline-flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
justify-content: center;
|
|
119
|
+
gap: 8px;
|
|
120
|
+
padding: 10px 18px;
|
|
121
|
+
border: 1px solid var(--rs-border);
|
|
122
|
+
border-radius: var(--rs-radius);
|
|
123
|
+
background: var(--rs-surface);
|
|
124
|
+
color: var(--rs-fg);
|
|
125
|
+
font: inherit;
|
|
126
|
+
font-weight: 500;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
text-decoration: none;
|
|
129
|
+
}
|
|
130
|
+
.rs-btn:hover, .rs-btn:focus-visible { background: var(--rs-bg-hover); }
|
|
131
|
+
|
|
132
|
+
.rs-btn-primary {
|
|
133
|
+
background: var(--rs-accent);
|
|
134
|
+
color: var(--rs-accent-fg);
|
|
135
|
+
border-color: transparent;
|
|
136
|
+
}
|
|
137
|
+
.rs-btn-primary:hover, .rs-btn-primary:focus-visible {
|
|
138
|
+
filter: brightness(1.05);
|
|
139
|
+
background: var(--rs-accent);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.rs-btn-ghost { background: transparent; }
|
|
143
|
+
|
|
144
|
+
.rs-btn-danger {
|
|
145
|
+
background: var(--rs-danger);
|
|
146
|
+
color: var(--rs-danger-fg);
|
|
147
|
+
border-color: transparent;
|
|
148
|
+
}
|
|
149
|
+
.rs-btn-danger:hover, .rs-btn-danger:focus-visible { filter: brightness(1.05); background: var(--rs-danger); }
|
|
150
|
+
|
|
151
|
+
.rs-message {
|
|
152
|
+
margin-top: 14px;
|
|
153
|
+
padding: 10px 12px;
|
|
154
|
+
border-radius: var(--rs-radius);
|
|
155
|
+
font-size: 14px;
|
|
156
|
+
border: 1px solid var(--rs-border);
|
|
157
|
+
background: var(--rs-bg);
|
|
158
|
+
}
|
|
159
|
+
.rs-message[data-rs-tone="error"] {
|
|
160
|
+
border-color: var(--rs-danger);
|
|
161
|
+
background: var(--rs-danger-bg);
|
|
162
|
+
color: var(--rs-danger);
|
|
163
|
+
}
|
|
164
|
+
.rs-message[data-rs-tone="success"] {
|
|
165
|
+
border-color: var(--rs-accent);
|
|
166
|
+
background: var(--rs-accent-bg);
|
|
167
|
+
color: var(--rs-accent);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.rs-section {
|
|
171
|
+
margin-top: 18px;
|
|
172
|
+
padding: 12px 14px;
|
|
173
|
+
border: 1px solid var(--rs-border);
|
|
174
|
+
border-radius: var(--rs-radius);
|
|
175
|
+
background: var(--rs-bg);
|
|
176
|
+
}
|
|
177
|
+
.rs-section > summary {
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
font-weight: 500;
|
|
180
|
+
}
|
|
181
|
+
.rs-section[open] > summary { margin-bottom: 12px; }
|
|
182
|
+
.rs-section-danger { border-color: var(--rs-danger); }
|
|
183
|
+
.rs-section-danger > summary { color: var(--rs-danger); }
|
|
184
|
+
|
|
185
|
+
.rs-account-summary {
|
|
186
|
+
display: grid;
|
|
187
|
+
grid-template-columns: max-content 1fr;
|
|
188
|
+
gap: 6px 16px;
|
|
189
|
+
margin: 0 0 8px;
|
|
190
|
+
font-size: 14px;
|
|
191
|
+
}
|
|
192
|
+
.rs-account-summary dt { color: var(--rs-fg-muted); font-weight: 500; }
|
|
193
|
+
.rs-account-summary dd { margin: 0; word-break: break-word; }
|
|
194
|
+
|
|
195
|
+
.rs-footer {
|
|
196
|
+
text-align: center;
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
color: var(--rs-fg-muted);
|
|
199
|
+
padding: 18px 16px 24px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
@media (max-width: 480px) {
|
|
203
|
+
.rs-card { padding: 20px; }
|
|
204
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/* regstack — default theme. Each variable is the seam at which a host
|
|
2
|
+
theme.css can override the look without touching core.css. */
|
|
3
|
+
|
|
4
|
+
:root {
|
|
5
|
+
--rs-bg: #ffffff;
|
|
6
|
+
--rs-bg-hover: #f3f4f6;
|
|
7
|
+
--rs-surface: #ffffff;
|
|
8
|
+
--rs-fg: #111827;
|
|
9
|
+
--rs-fg-muted: #4b5563;
|
|
10
|
+
--rs-border: #e5e7eb;
|
|
11
|
+
|
|
12
|
+
--rs-accent: #2563eb;
|
|
13
|
+
--rs-accent-fg: #ffffff;
|
|
14
|
+
--rs-accent-bg: rgba(37, 99, 235, 0.08);
|
|
15
|
+
|
|
16
|
+
--rs-danger: #b91c1c;
|
|
17
|
+
--rs-danger-fg: #ffffff;
|
|
18
|
+
--rs-danger-bg: rgba(185, 28, 28, 0.08);
|
|
19
|
+
|
|
20
|
+
--rs-radius: 6px;
|
|
21
|
+
--rs-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 4px 10px rgba(0, 0, 0, 0.04);
|
|
22
|
+
|
|
23
|
+
--rs-font-display: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", sans-serif;
|
|
24
|
+
--rs-font-body: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", sans-serif;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@media (prefers-color-scheme: dark) {
|
|
28
|
+
:root {
|
|
29
|
+
--rs-bg: #0b1220;
|
|
30
|
+
--rs-bg-hover: #14213a;
|
|
31
|
+
--rs-surface: #111a30;
|
|
32
|
+
--rs-fg: #e5e7eb;
|
|
33
|
+
--rs-fg-muted: #9ca3af;
|
|
34
|
+
--rs-border: #1f2a44;
|
|
35
|
+
--rs-accent: #60a5fa;
|
|
36
|
+
--rs-accent-fg: #0b1220;
|
|
37
|
+
--rs-accent-bg: rgba(96, 165, 250, 0.12);
|
|
38
|
+
--rs-danger: #f87171;
|
|
39
|
+
--rs-danger-fg: #0b1220;
|
|
40
|
+
--rs-danger-bg: rgba(248, 113, 113, 0.12);
|
|
41
|
+
--rs-shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.3);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// regstack — small client for the SSR pages. Reads its endpoints from
|
|
2
|
+
// data attributes on <body> so a single static file works for every host.
|
|
3
|
+
|
|
4
|
+
(function () {
|
|
5
|
+
"use strict";
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = "regstack.access_token";
|
|
8
|
+
const MFA_PENDING_KEY = "regstack.mfa_pending";
|
|
9
|
+
const body = document.body;
|
|
10
|
+
const apiPrefix = body.dataset.rsApi || "/api/auth";
|
|
11
|
+
const uiPrefix = body.dataset.rsUi || "/account";
|
|
12
|
+
const page = body.dataset.rsPage;
|
|
13
|
+
|
|
14
|
+
const messageEl = document.querySelector("[data-rs-message]");
|
|
15
|
+
|
|
16
|
+
function getToken() {
|
|
17
|
+
return window.localStorage.getItem(STORAGE_KEY);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function setToken(token) {
|
|
21
|
+
if (token) {
|
|
22
|
+
window.localStorage.setItem(STORAGE_KEY, token);
|
|
23
|
+
} else {
|
|
24
|
+
window.localStorage.removeItem(STORAGE_KEY);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function showMessage(text, tone) {
|
|
29
|
+
if (!messageEl) return;
|
|
30
|
+
messageEl.textContent = text;
|
|
31
|
+
messageEl.hidden = false;
|
|
32
|
+
if (tone) {
|
|
33
|
+
messageEl.dataset.rsTone = tone;
|
|
34
|
+
} else {
|
|
35
|
+
delete messageEl.dataset.rsTone;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function clearMessage() {
|
|
40
|
+
if (!messageEl) return;
|
|
41
|
+
messageEl.hidden = true;
|
|
42
|
+
messageEl.textContent = "";
|
|
43
|
+
delete messageEl.dataset.rsTone;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function tokenFromQuery() {
|
|
47
|
+
const params = new URLSearchParams(window.location.search);
|
|
48
|
+
return params.get("token");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function api(method, path, body, withAuth = false) {
|
|
52
|
+
const headers = { "content-type": "application/json" };
|
|
53
|
+
if (withAuth) {
|
|
54
|
+
const token = getToken();
|
|
55
|
+
if (!token) {
|
|
56
|
+
window.location.href = uiPrefix + "/login";
|
|
57
|
+
throw new Error("not authenticated");
|
|
58
|
+
}
|
|
59
|
+
headers["authorization"] = "Bearer " + token;
|
|
60
|
+
}
|
|
61
|
+
const res = await fetch(apiPrefix + path, {
|
|
62
|
+
method: method,
|
|
63
|
+
headers: headers,
|
|
64
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
65
|
+
});
|
|
66
|
+
let payload = null;
|
|
67
|
+
try {
|
|
68
|
+
payload = await res.json();
|
|
69
|
+
} catch (_) {
|
|
70
|
+
payload = null;
|
|
71
|
+
}
|
|
72
|
+
return { ok: res.ok, status: res.status, body: payload };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function formData(form) {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [key, value] of new FormData(form).entries()) {
|
|
78
|
+
out[key] = value;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function detailFromError(payload, fallback) {
|
|
84
|
+
if (!payload) return fallback;
|
|
85
|
+
if (typeof payload.detail === "string") return payload.detail;
|
|
86
|
+
if (Array.isArray(payload.detail) && payload.detail.length) {
|
|
87
|
+
return payload.detail.map((e) => e.msg || e).join("; ");
|
|
88
|
+
}
|
|
89
|
+
return fallback;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function on(formName, handler) {
|
|
93
|
+
const form = document.querySelector(`[data-rs-form="${formName}"]`);
|
|
94
|
+
if (!form) return;
|
|
95
|
+
form.addEventListener("submit", async (event) => {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
clearMessage();
|
|
98
|
+
const submitButton = form.querySelector("button[type='submit']");
|
|
99
|
+
if (submitButton) submitButton.disabled = true;
|
|
100
|
+
try {
|
|
101
|
+
await handler(formData(form), form);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
showMessage(err.message || String(err), "error");
|
|
104
|
+
} finally {
|
|
105
|
+
if (submitButton) submitButton.disabled = false;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// --- Page wiring -----------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async function wireLogin() {
|
|
113
|
+
if (getToken()) {
|
|
114
|
+
window.location.href = uiPrefix + "/me";
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
on("login", async (data) => {
|
|
118
|
+
const res = await api("POST", "/login", data);
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new Error(detailFromError(res.body, "Login failed."));
|
|
121
|
+
}
|
|
122
|
+
if (res.body && res.body.status === "mfa_required") {
|
|
123
|
+
window.sessionStorage.setItem(MFA_PENDING_KEY, res.body.mfa_pending_token);
|
|
124
|
+
window.location.href = uiPrefix + "/mfa-confirm";
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
setToken(res.body.access_token);
|
|
128
|
+
window.location.href = uiPrefix + "/me";
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function wireMfaConfirm() {
|
|
133
|
+
const pending = window.sessionStorage.getItem(MFA_PENDING_KEY);
|
|
134
|
+
if (!pending) {
|
|
135
|
+
window.location.href = uiPrefix + "/login";
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
on("mfa-confirm", async (data) => {
|
|
139
|
+
const res = await api("POST", "/login/mfa-confirm", {
|
|
140
|
+
mfa_pending_token: pending,
|
|
141
|
+
code: data.code,
|
|
142
|
+
});
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
throw new Error(detailFromError(res.body, "Code rejected."));
|
|
145
|
+
}
|
|
146
|
+
window.sessionStorage.removeItem(MFA_PENDING_KEY);
|
|
147
|
+
setToken(res.body.access_token);
|
|
148
|
+
window.location.href = uiPrefix + "/me";
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function wireRegister() {
|
|
153
|
+
if (getToken()) {
|
|
154
|
+
window.location.href = uiPrefix + "/me";
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
on("register", async (data) => {
|
|
158
|
+
const res = await api("POST", "/register", data);
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
throw new Error(detailFromError(res.body, "Registration failed."));
|
|
161
|
+
}
|
|
162
|
+
if (res.body && res.body.status === "pending_verification") {
|
|
163
|
+
showMessage(
|
|
164
|
+
"Account created — please check " + (data.email || "your email") + " for a verification link.",
|
|
165
|
+
"success"
|
|
166
|
+
);
|
|
167
|
+
} else {
|
|
168
|
+
showMessage("Account created. You can now sign in.", "success");
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function wireForgot() {
|
|
174
|
+
on("forgot", async (data) => {
|
|
175
|
+
const res = await api("POST", "/forgot-password", data);
|
|
176
|
+
if (!res.ok) {
|
|
177
|
+
throw new Error(detailFromError(res.body, "Request failed."));
|
|
178
|
+
}
|
|
179
|
+
showMessage(
|
|
180
|
+
"If an account exists for that email, a reset link has been sent.",
|
|
181
|
+
"success"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function wireReset() {
|
|
187
|
+
const tokenInput = document.querySelector("[data-rs-token]");
|
|
188
|
+
const queryToken = tokenFromQuery();
|
|
189
|
+
if (tokenInput && queryToken) tokenInput.value = queryToken;
|
|
190
|
+
on("reset", async (data) => {
|
|
191
|
+
if (!data.token) {
|
|
192
|
+
throw new Error("Missing reset token. Use the link from your email.");
|
|
193
|
+
}
|
|
194
|
+
const res = await api("POST", "/reset-password", {
|
|
195
|
+
token: data.token,
|
|
196
|
+
new_password: data.new_password,
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) {
|
|
199
|
+
throw new Error(detailFromError(res.body, "Reset failed."));
|
|
200
|
+
}
|
|
201
|
+
showMessage("Password reset. Redirecting to sign in…", "success");
|
|
202
|
+
setToken(null);
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
window.location.href = uiPrefix + "/login";
|
|
205
|
+
}, 1200);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function wireVerify() {
|
|
210
|
+
const status = document.querySelector("[data-rs-status]");
|
|
211
|
+
const token = tokenFromQuery();
|
|
212
|
+
if (!token) {
|
|
213
|
+
if (status) status.textContent = "Missing verification token.";
|
|
214
|
+
showMessage("No token in URL.", "error");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const res = await api("POST", "/verify", { token: token });
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const msg = detailFromError(res.body, "Verification failed.");
|
|
220
|
+
if (status) status.textContent = msg;
|
|
221
|
+
showMessage(msg, "error");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (status) status.textContent = "Email confirmed — you can sign in.";
|
|
225
|
+
showMessage("Email confirmed.", "success");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function wireConfirmEmailChange() {
|
|
229
|
+
const status = document.querySelector("[data-rs-status]");
|
|
230
|
+
const token = tokenFromQuery();
|
|
231
|
+
if (!token) {
|
|
232
|
+
if (status) status.textContent = "Missing token.";
|
|
233
|
+
showMessage("No token in URL.", "error");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const res = await api("POST", "/confirm-email-change", { token: token });
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
const msg = detailFromError(res.body, "Confirmation failed.");
|
|
239
|
+
if (status) status.textContent = msg;
|
|
240
|
+
showMessage(msg, "error");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
setToken(null); // bulk revoke fired — old session is dead
|
|
244
|
+
if (status) status.textContent = "Email updated — please sign in again.";
|
|
245
|
+
showMessage("Email updated. Sign in with your new address.", "success");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function wireMe() {
|
|
249
|
+
if (!getToken()) {
|
|
250
|
+
window.location.href = uiPrefix + "/login";
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const res = await api("GET", "/me", null, true);
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
setToken(null);
|
|
256
|
+
window.location.href = uiPrefix + "/login";
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
fillAccount(res.body);
|
|
260
|
+
|
|
261
|
+
on("update-profile", async (data) => {
|
|
262
|
+
const r = await api("PATCH", "/me", { full_name: data.full_name || null }, true);
|
|
263
|
+
if (!r.ok) throw new Error(detailFromError(r.body, "Update failed."));
|
|
264
|
+
fillAccount(r.body);
|
|
265
|
+
showMessage("Profile updated.", "success");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
on("change-password", async (data) => {
|
|
269
|
+
const r = await api("POST", "/change-password", data, true);
|
|
270
|
+
if (!r.ok) throw new Error(detailFromError(r.body, "Change failed."));
|
|
271
|
+
setToken(null);
|
|
272
|
+
showMessage("Password changed. Redirecting to sign in…", "success");
|
|
273
|
+
setTimeout(() => {
|
|
274
|
+
window.location.href = uiPrefix + "/login";
|
|
275
|
+
}, 1200);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
on("change-email", async (data) => {
|
|
279
|
+
const r = await api("POST", "/change-email", data, true);
|
|
280
|
+
if (!r.ok) throw new Error(detailFromError(r.body, "Change failed."));
|
|
281
|
+
showMessage(
|
|
282
|
+
"Confirmation sent to " + data.new_email + ". Click the link to finish.",
|
|
283
|
+
"success"
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
on("delete-account", async (data) => {
|
|
288
|
+
if (!window.confirm("This permanently deletes your account. Continue?")) return;
|
|
289
|
+
const r = await api("DELETE", "/account", data, true);
|
|
290
|
+
if (!r.ok) throw new Error(detailFromError(r.body, "Delete failed."));
|
|
291
|
+
setToken(null);
|
|
292
|
+
window.location.href = uiPrefix + "/login";
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
wireMfaSection(res.body);
|
|
296
|
+
|
|
297
|
+
const logoutBtn = document.querySelector("[data-rs-action='logout']");
|
|
298
|
+
if (logoutBtn) {
|
|
299
|
+
logoutBtn.addEventListener("click", async () => {
|
|
300
|
+
try {
|
|
301
|
+
await api("POST", "/logout", null, true);
|
|
302
|
+
} catch (_) {
|
|
303
|
+
// ignore — we're signing out client-side anyway
|
|
304
|
+
}
|
|
305
|
+
setToken(null);
|
|
306
|
+
window.location.href = uiPrefix + "/login";
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function wireMfaSection(user) {
|
|
312
|
+
const section = document.querySelector("[data-rs-mfa-section]");
|
|
313
|
+
if (!section) return;
|
|
314
|
+
|
|
315
|
+
const status = section.querySelector("[data-rs-mfa-status]");
|
|
316
|
+
const enableBlock = section.querySelector("[data-rs-mfa-enable]");
|
|
317
|
+
const disableBlock = section.querySelector("[data-rs-mfa-disable]");
|
|
318
|
+
const confirmForm = section.querySelector(
|
|
319
|
+
"[data-rs-form='phone-setup-confirm']"
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
if (user.is_mfa_enabled) {
|
|
323
|
+
if (status) {
|
|
324
|
+
status.textContent =
|
|
325
|
+
"Enabled — sign-in codes go to " + (user.phone_number || "your phone") + ".";
|
|
326
|
+
}
|
|
327
|
+
enableBlock.hidden = true;
|
|
328
|
+
disableBlock.hidden = false;
|
|
329
|
+
} else {
|
|
330
|
+
if (status) status.textContent = "Disabled. Add a phone number to enable.";
|
|
331
|
+
enableBlock.hidden = false;
|
|
332
|
+
disableBlock.hidden = true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
let setupPendingToken = null;
|
|
336
|
+
on("phone-setup-start", async (data) => {
|
|
337
|
+
const res = await api("POST", "/phone/start", data, true);
|
|
338
|
+
if (!res.ok) throw new Error(detailFromError(res.body, "Send failed."));
|
|
339
|
+
setupPendingToken = res.body.pending_token;
|
|
340
|
+
confirmForm.hidden = false;
|
|
341
|
+
showMessage("Code sent — enter it below to enable 2FA.", "success");
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
on("phone-setup-confirm", async (data) => {
|
|
345
|
+
if (!setupPendingToken) {
|
|
346
|
+
throw new Error("Request a code first.");
|
|
347
|
+
}
|
|
348
|
+
const res = await api(
|
|
349
|
+
"POST",
|
|
350
|
+
"/phone/confirm",
|
|
351
|
+
{ pending_token: setupPendingToken, code: data.code },
|
|
352
|
+
false
|
|
353
|
+
);
|
|
354
|
+
if (!res.ok) throw new Error(detailFromError(res.body, "Confirm failed."));
|
|
355
|
+
setupPendingToken = null;
|
|
356
|
+
showMessage("SMS 2FA enabled — sign in again to confirm.", "success");
|
|
357
|
+
setTimeout(() => window.location.reload(), 1200);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
on("mfa-disable", async (data) => {
|
|
361
|
+
const res = await fetch(apiPrefix + "/phone", {
|
|
362
|
+
method: "DELETE",
|
|
363
|
+
headers: {
|
|
364
|
+
"content-type": "application/json",
|
|
365
|
+
authorization: "Bearer " + getToken(),
|
|
366
|
+
},
|
|
367
|
+
body: JSON.stringify(data),
|
|
368
|
+
});
|
|
369
|
+
let payload = null;
|
|
370
|
+
try {
|
|
371
|
+
payload = await res.json();
|
|
372
|
+
} catch (_) {
|
|
373
|
+
payload = null;
|
|
374
|
+
}
|
|
375
|
+
if (!res.ok) {
|
|
376
|
+
throw new Error(detailFromError(payload, "Disable failed."));
|
|
377
|
+
}
|
|
378
|
+
showMessage("SMS 2FA disabled.", "success");
|
|
379
|
+
setTimeout(() => window.location.reload(), 1200);
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function fillAccount(user) {
|
|
384
|
+
if (!user) return;
|
|
385
|
+
document.querySelectorAll("[data-rs-field]").forEach((el) => {
|
|
386
|
+
const key = el.dataset.rsField;
|
|
387
|
+
let value = user[key];
|
|
388
|
+
if (key === "is_verified") value = value ? "yes" : "no";
|
|
389
|
+
if (key === "created_at" && value) value = new Date(value).toLocaleString();
|
|
390
|
+
if (value === null || value === undefined || value === "") value = "—";
|
|
391
|
+
el.textContent = String(value);
|
|
392
|
+
});
|
|
393
|
+
const fnInput = document.querySelector(
|
|
394
|
+
"[data-rs-form='update-profile'] input[name='full_name']"
|
|
395
|
+
);
|
|
396
|
+
if (fnInput) fnInput.value = user.full_name || "";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Dispatch by page name set on <html>/<body>.
|
|
400
|
+
switch (page) {
|
|
401
|
+
case "login": wireLogin(); break;
|
|
402
|
+
case "register": wireRegister(); break;
|
|
403
|
+
case "forgot": wireForgot(); break;
|
|
404
|
+
case "reset": wireReset(); break;
|
|
405
|
+
case "verify": wireVerify(); break;
|
|
406
|
+
case "confirm-email-change": wireConfirmEmailChange(); break;
|
|
407
|
+
case "mfa-confirm": wireMfaConfirm(); break;
|
|
408
|
+
case "me": wireMe(); break;
|
|
409
|
+
default: break;
|
|
410
|
+
}
|
|
411
|
+
})();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Confirm email change — {{ app_name }}{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<h1 class="rs-title">Confirming your new email…</h1>
|
|
5
|
+
<p class="rs-meta" data-rs-status="pending">One moment while we update your account.</p>
|
|
6
|
+
<input type="hidden" data-rs-token>
|
|
7
|
+
<p class="rs-meta">
|
|
8
|
+
<a href="{{ ui_prefix }}/login">Sign in</a>
|
|
9
|
+
</p>
|
|
10
|
+
{% endblock %}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Forgot password — {{ app_name }}{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<h1 class="rs-title">Reset your password</h1>
|
|
5
|
+
<p class="rs-meta">Enter your email and we'll send a reset link.</p>
|
|
6
|
+
<form class="rs-form" data-rs-form="forgot" autocomplete="on">
|
|
7
|
+
<label class="rs-field">
|
|
8
|
+
<span class="rs-label">Email</span>
|
|
9
|
+
<input type="email" name="email" required autocomplete="email" autofocus>
|
|
10
|
+
</label>
|
|
11
|
+
<button type="submit" class="rs-btn rs-btn-primary">Send reset link</button>
|
|
12
|
+
</form>
|
|
13
|
+
<p class="rs-meta"><a href="{{ ui_prefix }}/login">Back to sign in</a></p>
|
|
14
|
+
{% endblock %}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
{% block title %}Sign in — {{ app_name }}{% endblock %}
|
|
3
|
+
{% block content %}
|
|
4
|
+
<h1 class="rs-title">Sign in</h1>
|
|
5
|
+
<form class="rs-form" data-rs-form="login" autocomplete="on">
|
|
6
|
+
<label class="rs-field">
|
|
7
|
+
<span class="rs-label">Email</span>
|
|
8
|
+
<input type="email" name="email" required autocomplete="email" autofocus>
|
|
9
|
+
</label>
|
|
10
|
+
<label class="rs-field">
|
|
11
|
+
<span class="rs-label">Password</span>
|
|
12
|
+
<input type="password" name="password" required minlength="8" autocomplete="current-password">
|
|
13
|
+
</label>
|
|
14
|
+
<button type="submit" class="rs-btn rs-btn-primary">Sign in</button>
|
|
15
|
+
</form>
|
|
16
|
+
<p class="rs-meta">
|
|
17
|
+
{% if allow_registration %}
|
|
18
|
+
No account? <a href="{{ ui_prefix }}/register">Create one</a>.
|
|
19
|
+
{% endif %}
|
|
20
|
+
{% if enable_password_reset %}
|
|
21
|
+
<br><a href="{{ ui_prefix }}/forgot">Forgot password?</a>
|
|
22
|
+
{% endif %}
|
|
23
|
+
</p>
|
|
24
|
+
{% endblock %}
|