settings.go
⎇
Raw
1package views
2
3import (
4 "strconv"
5
6 "hearthforge/internal/config"
7 "hearthforge/internal/db"
8 "hearthforge/internal/util"
9
10 g "maragu.dev/gomponents"
11 . "maragu.dev/gomponents/html"
12)
13
14// successMessages maps the success query parameter to its message.
15var successMessages = map[string]string{
16 "password": "Password updated.",
17 "password_removed": "Password removed.",
18 "passkey_revoked": "Passkey revoked.",
19 "theme": "Theme preference saved.",
20 "user_created": "Account created.",
21 "user_deleted": "Account deleted.",
22 "user_reset": "Account reset. Passkeys and sessions were removed.",
23 "user_approved": "Account approved.",
24 "user_denied": "Account denied.",
25 "all_approved": "All pending accounts approved.",
26 "all_denied": "All pending accounts denied.",
27 "ssh_key_added": "SSH key added.",
28 "ssh_key_deleted": "SSH key removed.",
29 "repo_dropped": "Repository data dropped.",
30 "repos_dropped": "All missing repositories dropped.",
31}
32
33// Settings renders the user settings page, including the admin sections.
34// reauth is the sign-in link for a session too old to change auth methods,
35// or empty.
36func Settings(cfg *config.Config, user *db.SessionUser, hasPassword bool,
37 passkeys []db.Passkey, sshKeys []db.SSHKey, theme, success, errMsg, reauth string,
38 pendingUsers []db.PendingUser, missingRepos []db.RepoCounts,
39) g.Node {
40 successMsg := successMessages[success]
41 maxPassword := strconv.Itoa(cfg.MaxPasswordBytes)
42 // The last auth method may not be revoked.
43 lastMethod := !hasPassword && len(passkeys) <= 1
44
45 return Layout(Page{Title: "Settings", User: user, Cfg: cfg},
46 Div(Class("container container-narrow"),
47 H1(Class("page-title"), g.Text("Settings")),
48 g.If(successMsg != "", P(Class("form-success"), g.Text(successMsg))),
49 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
50
51 Div(Class("form-card"),
52 H2(Class("section-title"), g.Text("Avatar")),
53 Div(Class("avatar-settings"),
54 Avatar(&user.ID, &user.AvatarVersion, 80),
55 Div(Class("avatar-actions"),
56 Form(Method("POST"), Action("/settings/avatar"),
57 EncType("multipart/form-data"),
58 Div(Class("form-group"),
59 Input(Type("file"), Name("avatar"), Accept("image/*"),
60 Class("form-input"), Required()),
61 ),
62 Div(Class("form-actions"),
63 Button(Type("submit"), Class("btn btn-sm btn-primary"),
64 g.Text("Upload avatar")),
65 ),
66 ),
67 Form(Method("POST"), Action("/settings/avatar/delete"), Class("inline-form"),
68 Button(Type("submit"), Class("btn btn-sm"), g.Text("Remove avatar")),
69 ),
70 ),
71 ),
72 ),
73
74 Div(Class("form-card"),
75 H2(Class("section-title"), g.Text("Appearance")),
76 Form(Method("POST"), Action("/settings/theme"),
77 Div(Class("theme-options"),
78 themeOption("auto", "Auto", theme),
79 themeOption("light", "Light", theme),
80 themeOption("dark", "Dark", theme),
81 ),
82 Div(Class("form-actions"),
83 Button(Class("btn btn-primary"), Type("submit"), g.Text("Save")),
84 ),
85 ),
86 ),
87
88 g.If(reauth != "", P(Class("text-muted"),
89 g.Text("Changing passwords or passkeys needs a recent sign-in. "),
90 A(Href(reauth), g.Text("Sign in again")))),
91
92 Div(Class("form-card"),
93 H2(Class("section-title"), g.Text("Password")),
94 P(Class("text-muted"), g.Text(passwordState(hasPassword))),
95 Form(Method("POST"), Action("/settings/password"), Class("settings-form"),
96 g.If(hasPassword, Div(Class("form-group"),
97 Label(Class("form-label"), For("current_password"), g.Text("Current password")),
98 Input(Class("form-input"), Type("password"), ID("current_password"),
99 Name("current_password"), AutoComplete("current-password"),
100 MaxLength(maxPassword)),
101 )),
102 Div(Class("form-group"),
103 Label(Class("form-label"), For("new_password"), g.Text(newPasswordLabel(hasPassword))),
104 Input(Class("form-input"), Type("password"), ID("new_password"),
105 Name("new_password"), AutoComplete("new-password"), MaxLength(maxPassword)),
106 ),
107 Div(Class("form-group"),
108 Label(Class("form-label"), For("confirm_password"), g.Text("Confirm password")),
109 Input(Class("form-input"), Type("password"), ID("confirm_password"),
110 Name("confirm_password"), AutoComplete("new-password"), MaxLength(maxPassword)),
111 ),
112 Div(Class("form-actions"),
113 Button(Class("btn btn-primary"), Type("submit"),
114 g.Text(passwordButtonLabel(hasPassword))),
115 ),
116 ),
117 g.If(hasPassword && len(passkeys) > 0,
118 Form(Method("POST"), Action("/settings/password/remove"),
119 Style("margin-top: var(--space-4)"),
120 Button(Class("btn btn-danger btn-sm"), Type("submit"),
121 Data("confirm", "Remove password? You will need a passkey to sign in."),
122 g.Text("Remove password")),
123 )),
124 ),
125
126 Div(Class("form-card"),
127 H2(Class("section-title"), g.Text("Passkeys")),
128 g.If(len(passkeys) > 0, Div(Class("passkey-list"),
129 g.Map(passkeys, func(pk db.Passkey) g.Node {
130 return Div(Class("passkey-item"),
131 Span(Class("passkey-date"), g.Text("Added "+util.FormatDate(pk.CreatedAt))),
132 Form(Method("POST"), Action("/settings/passkey/revoke"),
133 Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(pk.ID, 10))),
134 Button(Class("btn btn-danger btn-sm"), Type("submit"),
135 g.If(lastMethod, Disabled()),
136 g.If(lastMethod, Title("Register another auth method first")),
137 g.Text("Revoke")),
138 ),
139 )
140 }),
141 )),
142 Div(ID("passkey-section"), Style("display:none"),
143 Button(ID("add-passkey-btn"), Class("btn btn-secondary"), Type("button"),
144 g.Text("Add passkey")),
145 P(ID("passkey-status"), Class("text-muted")),
146 ),
147 NoScript(
148 P(Class("text-muted"), g.Text("Enable JavaScript to register or add passkeys.")),
149 ),
150 ),
151
152 Div(Class("form-card"),
153 H2(Class("section-title"), g.Text("SSH Keys")),
154 g.If(len(sshKeys) > 0, Div(Class("passkey-list"),
155 g.Map(sshKeys, func(key db.SSHKey) g.Node {
156 return Div(Class("passkey-item"),
157 Div(Class("ssh-key-info"),
158 Span(Class("ssh-key-name"), g.Text(key.Name)),
159 Span(Class("passkey-date ssh-key-fingerprint"), g.Text(key.Fingerprint)),
160 Span(Class("passkey-date"), g.Text("Added "+util.FormatDate(key.CreatedAt))),
161 ),
162 Form(Method("POST"), Action("/settings/ssh-keys/delete"),
163 Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(key.ID, 10))),
164 Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Remove")),
165 ),
166 )
167 }),
168 )),
169 Form(Method("POST"), Action("/settings/ssh-keys"), Class("settings-form"),
170 Style("margin-top: var(--space-4)"),
171 Div(Class("form-group"),
172 Label(Class("form-label"), For("ssh_key_name"), g.Text("Name")),
173 Input(Class("form-input"), Type("text"), ID("ssh_key_name"), Name("name"),
174 Placeholder("e.g. My laptop"), AutoComplete("off")),
175 ),
176 Div(Class("form-group"),
177 Label(Class("form-label"), For("ssh_public_key"), g.Text("Public key")),
178 Textarea(Class("form-input form-textarea"), ID("ssh_public_key"),
179 Name("public_key"), Placeholder("ssh-ed25519 AAAA..."), Rows("3"), Required()),
180 ),
181 Div(Class("form-actions"),
182 Button(Class("btn btn-primary"), Type("submit"), g.Text("Add SSH key")),
183 ),
184 ),
185 ),
186
187 g.If(user.IsAdmin, registrationQueue(pendingUsers)),
188 g.If(user.IsAdmin, missingRepoList(missingRepos)),
189 g.If(user.IsAdmin, userManagement()),
190 ),
191 Script(Type("module"), Src("/assets/passkey-settings.js")),
192 )
193}
194
195func themeOption(value, label, current string) g.Node {
196 return Label(Class("theme-option"),
197 Input(Type("radio"), Name("theme"), Value(value), g.If(current == value, Checked())),
198 g.Text(label),
199 )
200}
201
202func passwordState(hasPassword bool) string {
203 if hasPassword {
204 return "Password is set."
205 }
206 return "No password set."
207}
208
209func newPasswordLabel(hasPassword bool) string {
210 if hasPassword {
211 return "New password"
212 }
213 return "Password"
214}
215
216func passwordButtonLabel(hasPassword bool) string {
217 if hasPassword {
218 return "Change password"
219 }
220 return "Set password"
221}
222
223func registrationQueue(pendingUsers []db.PendingUser) g.Node {
224 return Div(Class("form-card"),
225 H2(Class("section-title"), g.Text("Registration queue")),
226 g.If(len(pendingUsers) == 0,
227 P(Style("font-size: var(--text-sm); color: var(--color-text-muted); margin: 0"),
228 g.Text("No pending registrations."))),
229 g.If(len(pendingUsers) > 0, Div(
230 Div(Class("queue-bulk-actions"),
231 Form(Method("POST"), Action("/admin/users/approve-all"),
232 Button(Class("btn btn-sm btn-primary"), Type("submit"), g.Text("Accept all")),
233 ),
234 Form(Method("POST"), Action("/admin/users/deny-all"),
235 Button(Class("btn btn-sm btn-danger"), Type("submit"), g.Text("Deny all")),
236 ),
237 ),
238 Ul(Class("queue-list"),
239 g.Map(pendingUsers, func(u db.PendingUser) g.Node {
240 id := strconv.FormatInt(u.ID, 10)
241 return Li(Class("queue-item"),
242 Div(Class("queue-item-meta"),
243 Div(Class("queue-item-header"),
244 Strong(g.Text(u.Username)),
245 Span(Class("queue-item-date"), g.Text(util.FormatDateTime(u.CreatedAt))),
246 ),
247 g.Iff(u.RegisterApplication != nil && *u.RegisterApplication != "", func() g.Node {
248 return P(Class("queue-item-answer"), g.Text(*u.RegisterApplication))
249 }),
250 ),
251 Div(Class("queue-item-actions"),
252 Form(Method("POST"), Action("/admin/users/approve"),
253 Input(Type("hidden"), Name("id"), Value(id)),
254 Button(Class("btn btn-sm btn-primary"), Type("submit"), g.Text("Accept")),
255 ),
256 Form(Method("POST"), Action("/admin/users/deny"),
257 Input(Type("hidden"), Name("id"), Value(id)),
258 Button(Class("btn btn-sm btn-danger"), Type("submit"), g.Text("Deny")),
259 ),
260 ),
261 )
262 }),
263 ),
264 )),
265 )
266}
267
268// missingRepoList shows repos whose git directory is gone. Dropping one
269// deletes its issues, patches, releases, and CI runs.
270func missingRepoList(repos []db.RepoCounts) g.Node {
271 return Div(Class("form-card"),
272 H2(Class("section-title"), g.Text("Missing repositories")),
273 g.If(len(repos) == 0,
274 P(Style("font-size: var(--text-sm); color: var(--color-text-muted); margin: 0"),
275 g.Text("Every repository has its git directory on disk."))),
276 g.If(len(repos) > 0, Div(
277 P(Class("text-muted"), g.Text("These repositories have no git directory on disk. "+
278 "Put the directory back to restore them, or drop their data.")),
279 Div(Class("queue-bulk-actions"),
280 Details(Class("confirm-details"),
281 Summary(Class("btn btn-sm btn-danger"), g.Text("Drop all")),
282 Div(Class("confirm-popup"),
283 g.Text("Delete the issues, patches, releases, and CI runs of every missing repository?"),
284 Form(Method("POST"), Action("/admin/repos/drop-all"), Class("inline-form"),
285 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, drop all")),
286 ),
287 ),
288 ),
289 ),
290 Ul(Class("queue-list queue-list-popups"),
291 g.Map(repos, func(r db.RepoCounts) g.Node {
292 id := strconv.FormatInt(r.ID, 10)
293 return Li(Class("queue-item"),
294 Div(Class("queue-item-meta"),
295 Div(Class("queue-item-header"),
296 Strong(g.Text(r.Name)),
297 Span(Class("queue-item-date"), g.Text(countLabel(r.Issues, "issue", "issues")+", "+countLabel(r.Patches, "patch", "patches"))),
298 ),
299 ),
300 Div(Class("queue-item-actions"),
301 Details(Class("confirm-details"),
302 Summary(Class("btn btn-sm btn-danger"), g.Text("Drop")),
303 Div(Class("confirm-popup"),
304 g.Textf("Delete all data of %s?", r.Name),
305 Form(Method("POST"), Action("/admin/repos/drop"), Class("inline-form"),
306 Input(Type("hidden"), Name("id"), Value(id)),
307 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, drop")),
308 ),
309 ),
310 ),
311 ),
312 )
313 }),
314 ),
315 )),
316 )
317}
318
319// countLabel renders "1 issue" or "3 issues".
320func countLabel(n int, one, many string) string {
321 if n == 1 {
322 return "1 " + one
323 }
324 return strconv.Itoa(n) + " " + many
325}
326
327func userManagement() g.Node {
328 return Div(Class("form-card"),
329 H2(Class("section-title"), g.Text("User Management")),
330 H3(g.Text("Create account")),
331 Form(Method("POST"), Action("/admin/users"), Class("settings-form"),
332 Style("margin-top: var(--space-4)"),
333 Div(Class("form-group"),
334 Label(Class("form-label"), For("new_username"), g.Text("Username")),
335 Input(Class("form-input"), Type("text"), ID("new_username"), Name("username"),
336 AutoComplete("off")),
337 ),
338 Div(Class("form-group"),
339 Label(Class("form-label"), For("new_user_password"), g.Text("Password")),
340 Input(Class("form-input"), Type("password"), ID("new_user_password"),
341 Name("password"), AutoComplete("new-password")),
342 ),
343 Div(Class("form-actions"),
344 Button(Class("btn btn-primary"), Type("submit"), g.Text("Create account")),
345 ),
346 ),
347 H3(Style("margin-top: var(--space-6)"), g.Text("Reset account")),
348 P(Class("text-muted"), g.Text("Sets a new password and removes all passkeys and sessions of the user.")),
349 Form(Method("POST"), Action("/admin/users/reset"), Class("settings-form"),
350 Style("margin-top: var(--space-4)"),
351 Div(Class("form-group"),
352 Label(Class("form-label"), For("reset_username"), g.Text("Username")),
353 Input(Class("form-input"), Type("text"), ID("reset_username"), Name("username"),
354 AutoComplete("off")),
355 ),
356 Div(Class("form-group"),
357 Label(Class("form-label"), For("reset_password"), g.Text("New password")),
358 Input(Class("form-input"), Type("password"), ID("reset_password"),
359 Name("password"), AutoComplete("new-password")),
360 ),
361 Div(Class("form-actions"),
362 Button(Class("btn btn-danger"), Type("submit"), g.Text("Reset account")),
363 ),
364 ),
365 H3(Style("margin-top: var(--space-6)"), g.Text("Delete account")),
366 Form(Method("POST"), Action("/admin/users/delete"), Class("settings-form"),
367 Style("margin-top: var(--space-4)"),
368 Div(Class("form-group"),
369 Label(Class("form-label"), For("del_username"), g.Text("Username")),
370 Input(Class("form-input"), Type("text"), ID("del_username"), Name("username"),
371 AutoComplete("off")),
372 ),
373 Div(Class("form-actions"),
374 Button(Class("btn btn-danger"), Type("submit"), g.Text("Delete account")),
375 ),
376 ),
377 )
378}
379