package views import ( "strconv" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // successMessages maps the success query parameter to its message. var successMessages = map[string]string{ "password": "Password updated.", "password_removed": "Password removed.", "passkey_revoked": "Passkey revoked.", "theme": "Theme preference saved.", "user_created": "Account created.", "user_deleted": "Account deleted.", "user_approved": "Account approved.", "user_denied": "Account denied.", "all_approved": "All pending accounts approved.", "all_denied": "All pending accounts denied.", "ssh_key_added": "SSH key added.", "ssh_key_deleted": "SSH key removed.", } // Settings renders the user settings page, including the admin sections. func Settings(cfg *config.Config, user *db.SessionUser, hasPassword bool, passkeys []db.Passkey, sshKeys []db.SSHKey, theme, success, errMsg string, pendingUsers []db.PendingUser, ) g.Node { successMsg := successMessages[success] maxPassword := strconv.Itoa(cfg.MaxPasswordBytes) // The last auth method may not be revoked. lastMethod := !hasPassword && len(passkeys) <= 1 return Layout(Page{Title: "Settings", User: user, Cfg: cfg}, Div(Class("container container-narrow"), H1(Class("page-title"), g.Text("Settings")), g.If(successMsg != "", P(Class("form-success"), g.Text(successMsg))), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Div(Class("form-card"), H2(Class("section-title"), g.Text("Avatar")), Div(Class("avatar-settings"), Avatar(&user.ID, &user.AvatarVersion, 80), Div(Class("avatar-actions"), Form(Method("POST"), Action("/settings/avatar"), EncType("multipart/form-data"), Div(Class("form-group"), Input(Type("file"), Name("avatar"), Accept("image/*"), Class("form-input"), Required()), ), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Upload avatar")), ), ), Form(Method("POST"), Action("/settings/avatar/delete"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm"), g.Text("Remove avatar")), ), ), ), ), Div(Class("form-card"), H2(Class("section-title"), g.Text("Appearance")), Form(Method("POST"), Action("/settings/theme"), Div(Class("theme-options"), themeOption("auto", "Auto", theme), themeOption("light", "Light", theme), themeOption("dark", "Dark", theme), ), Div(Class("form-actions"), Button(Class("btn btn-primary"), Type("submit"), g.Text("Save")), ), ), ), Div(Class("form-card"), H2(Class("section-title"), g.Text("Password")), P(Class("text-muted"), g.Text(passwordState(hasPassword))), Form(Method("POST"), Action("/settings/password"), Class("settings-form"), g.If(hasPassword, Div(Class("form-group"), Label(Class("form-label"), For("current_password"), g.Text("Current password")), Input(Class("form-input"), Type("password"), ID("current_password"), Name("current_password"), AutoComplete("current-password"), MaxLength(maxPassword)), )), Div(Class("form-group"), Label(Class("form-label"), For("new_password"), g.Text(newPasswordLabel(hasPassword))), Input(Class("form-input"), Type("password"), ID("new_password"), Name("new_password"), AutoComplete("new-password"), MaxLength(maxPassword)), ), Div(Class("form-group"), Label(Class("form-label"), For("confirm_password"), g.Text("Confirm password")), Input(Class("form-input"), Type("password"), ID("confirm_password"), Name("confirm_password"), AutoComplete("new-password"), MaxLength(maxPassword)), ), Div(Class("form-actions"), Button(Class("btn btn-primary"), Type("submit"), g.Text(passwordButtonLabel(hasPassword))), ), ), g.If(hasPassword && len(passkeys) > 0, Form(Method("POST"), Action("/settings/password/remove"), Style("margin-top: var(--space-4)"), Button(Class("btn btn-danger btn-sm"), Type("submit"), Data("confirm", "Remove password? You will need a passkey to sign in."), g.Text("Remove password")), )), ), Div(Class("form-card"), H2(Class("section-title"), g.Text("Passkeys")), g.If(len(passkeys) > 0, Div(Class("passkey-list"), g.Map(passkeys, func(pk db.Passkey) g.Node { return Div(Class("passkey-item"), Span(Class("passkey-date"), g.Text("Added "+util.FormatDate(pk.CreatedAt))), Form(Method("POST"), Action("/settings/passkey/revoke"), Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(pk.ID, 10))), Button(Class("btn btn-danger btn-sm"), Type("submit"), g.If(lastMethod, Disabled()), g.If(lastMethod, Title("Register another auth method first")), g.Text("Revoke")), ), ) }), )), Div(ID("passkey-section"), Style("display:none"), Button(ID("add-passkey-btn"), Class("btn btn-secondary"), Type("button"), g.Text("Add passkey")), P(ID("passkey-status"), Class("text-muted")), ), NoScript( P(Class("text-muted"), g.Text("Enable JavaScript to register or add passkeys.")), ), ), Div(Class("form-card"), H2(Class("section-title"), g.Text("SSH Keys")), g.If(len(sshKeys) > 0, Div(Class("passkey-list"), g.Map(sshKeys, func(key db.SSHKey) g.Node { return Div(Class("passkey-item"), Div(Class("ssh-key-info"), Span(Class("ssh-key-name"), g.Text(key.Name)), Span(Class("passkey-date ssh-key-fingerprint"), g.Text(key.Fingerprint)), Span(Class("passkey-date"), g.Text("Added "+util.FormatDate(key.CreatedAt))), ), Form(Method("POST"), Action("/settings/ssh-keys/delete"), Input(Type("hidden"), Name("id"), Value(strconv.FormatInt(key.ID, 10))), Button(Class("btn btn-danger btn-sm"), Type("submit"), g.Text("Remove")), ), ) }), )), Form(Method("POST"), Action("/settings/ssh-keys"), Class("settings-form"), Style("margin-top: var(--space-4)"), Div(Class("form-group"), Label(Class("form-label"), For("ssh_key_name"), g.Text("Name")), Input(Class("form-input"), Type("text"), ID("ssh_key_name"), Name("name"), Placeholder("e.g. My laptop"), AutoComplete("off")), ), Div(Class("form-group"), Label(Class("form-label"), For("ssh_public_key"), g.Text("Public key")), Textarea(Class("form-input form-textarea"), ID("ssh_public_key"), Name("public_key"), Placeholder("ssh-ed25519 AAAA..."), Rows("3"), Required()), ), Div(Class("form-actions"), Button(Class("btn btn-primary"), Type("submit"), g.Text("Add SSH key")), ), ), ), g.If(user.IsAdmin, registrationQueue(pendingUsers)), g.If(user.IsAdmin, userManagement()), ), Script(Type("module"), Src("/assets/passkey-settings.js")), ) } func themeOption(value, label, current string) g.Node { return Label(Class("theme-option"), Input(Type("radio"), Name("theme"), Value(value), g.If(current == value, Checked())), g.Text(label), ) } func passwordState(hasPassword bool) string { if hasPassword { return "Password is set." } return "No password set." } func newPasswordLabel(hasPassword bool) string { if hasPassword { return "New password" } return "Password" } func passwordButtonLabel(hasPassword bool) string { if hasPassword { return "Change password" } return "Set password" } func registrationQueue(pendingUsers []db.PendingUser) g.Node { return Div(Class("form-card"), H2(Class("section-title"), g.Text("Registration queue")), g.If(len(pendingUsers) == 0, P(Style("font-size: var(--text-sm); color: var(--color-text-muted); margin: 0"), g.Text("No pending registrations."))), g.If(len(pendingUsers) > 0, Div( Div(Class("queue-bulk-actions"), Form(Method("POST"), Action("/admin/users/approve-all"), Button(Class("btn btn-sm btn-primary"), Type("submit"), g.Text("Accept all")), ), Form(Method("POST"), Action("/admin/users/deny-all"), Button(Class("btn btn-sm btn-danger"), Type("submit"), g.Text("Deny all")), ), ), Ul(Class("queue-list"), g.Map(pendingUsers, func(u db.PendingUser) g.Node { id := strconv.FormatInt(u.ID, 10) return Li(Class("queue-item"), Div(Class("queue-item-meta"), Div(Class("queue-item-header"), Strong(g.Text(u.Username)), Span(Class("queue-item-date"), g.Text(util.FormatDateTime(u.CreatedAt))), ), g.Iff(u.RegisterApplication != nil && *u.RegisterApplication != "", func() g.Node { return P(Class("queue-item-answer"), g.Text(*u.RegisterApplication)) }), ), Div(Class("queue-item-actions"), Form(Method("POST"), Action("/admin/users/approve"), Input(Type("hidden"), Name("id"), Value(id)), Button(Class("btn btn-sm btn-primary"), Type("submit"), g.Text("Accept")), ), Form(Method("POST"), Action("/admin/users/deny"), Input(Type("hidden"), Name("id"), Value(id)), Button(Class("btn btn-sm btn-danger"), Type("submit"), g.Text("Deny")), ), ), ) }), ), )), ) } func userManagement() g.Node { return Div(Class("form-card"), H2(Class("section-title"), g.Text("User Management")), H3(g.Text("Create account")), Form(Method("POST"), Action("/admin/users"), Class("settings-form"), Style("margin-top: var(--space-4)"), Div(Class("form-group"), Label(Class("form-label"), For("new_username"), g.Text("Username")), Input(Class("form-input"), Type("text"), ID("new_username"), Name("username"), AutoComplete("off")), ), Div(Class("form-group"), Label(Class("form-label"), For("new_user_password"), g.Text("Password")), Input(Class("form-input"), Type("password"), ID("new_user_password"), Name("password"), AutoComplete("new-password")), ), Div(Class("form-actions"), Button(Class("btn btn-primary"), Type("submit"), g.Text("Create account")), ), ), H3(Style("margin-top: var(--space-6)"), g.Text("Delete account")), Form(Method("POST"), Action("/admin/users/delete"), Class("settings-form"), Style("margin-top: var(--space-4)"), Div(Class("form-group"), Label(Class("form-label"), For("del_username"), g.Text("Username")), Input(Class("form-input"), Type("text"), ID("del_username"), Name("username"), AutoComplete("off")), ), Div(Class("form-actions"), Button(Class("btn btn-danger"), Type("submit"), g.Text("Delete account")), ), ), ) }