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