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