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