Hypercode/alex/hypercodePublic

Code

  1. hypercode
  2. views
  3. pages
  4. settings.go
settings.go466 lines
package pages

import (
	"fmt"
	"net/http"
	"time"

	"github.com/hypercodehq/hypercode/database/models"
	"github.com/hypercodehq/libhtml"
	"github.com/hypercodehq/libhtml/attr"
	"github.com/hypercodehq/hypercode/views/components/layouts"
	"github.com/hypercodehq/hypercode/views/components/ui"
)

type SettingsData struct {
	User                 *models.User
	DisplayNameError     string
	UsernameError        string
	CurrentPasswordError string
	NewPasswordError     string
	ConfirmPasswordError string
	GeneralSuccess       string
	PasswordSuccess      string
	DisplayName          string
	Username             string
	AccessTokens         []*models.AccessToken
	NewAccessToken       string
	AccessTokenSuccess   string
	AccessTokenError     string
}

func Settings(r *http.Request, data *SettingsData) html.Node {
	if data == nil {
		data = &SettingsData{}
	}

	// Populate form values from user data if not set
	if data.User != nil {
		if data.DisplayName == "" {
			data.DisplayName = data.User.DisplayName
		}
		if data.Username == "" {
			data.Username = data.User.Username
		}
	}

	return layouts.Main(r,
		"Settings",
		html.Main(
			attr.Class("min-h-[calc(100vh-61px)] w-full mx-auto max-w-7xl space-y-6 py-8 px-4"),
			html.H1(
				attr.Class("font-semibold text-2xl mb-6"),
				html.Text("Settings"),
			),

			// General Settings Card
			ui.Card(ui.CardProps{
				Title:       "General",
				Description: "Update your account information",
				Content: html.Div(
					attr.Class("space-y-4"),
					html.If(data.GeneralSuccess != "", html.Div(
						attr.Class("p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200 text-sm"),
						html.Text(data.GeneralSuccess),
					)),
					html.Form(
						attr.Id("general-settings-form"),
						attr.Method("POST"),
						attr.Action("/settings/general"),
						attr.Class("space-y-4"),
						attr.Attribute{Key: "data-original-username", Value: data.User.Username},
						ui.FormField(ui.FormFieldProps{
							Label:       "Display Name",
							Id:          "display_name",
							Name:        "display_name",
							Type:        "text",
							Placeholder: "John Doe",
							Icon:        ui.IconUser,
							Required:    true,
							Value:       data.DisplayName,
							Error:       data.DisplayNameError,
						}),
						ui.FormField(ui.FormFieldProps{
							Label:       "Username",
							Id:          "username",
							Name:        "username",
							Type:        "text",
							Placeholder: "johndoe",
							Icon:        ui.IconAtSign,
							Required:    true,
							Value:       data.Username,
							Error:       data.UsernameError,
						}),
						html.Div(
							attr.Class("flex justify-end"),
							ui.Button(
								ui.ButtonProps{
									Variant: ui.ButtonPrimary,
									Type:    "submit",
								},
								html.Text("Save Changes"),
							),
						),
					),
				),
			}),

			// Password Settings Card
			ui.Card(ui.CardProps{
				Title:       "Password",
				Description: "Change your password",
				Content: html.Div(
					attr.Class("space-y-4"),
					html.If(data.PasswordSuccess != "", html.Div(
						attr.Class("p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200 text-sm"),
						html.Text(data.PasswordSuccess),
					)),
					html.Form(
						attr.Method("POST"),
						attr.Action("/settings/password"),
						attr.Class("space-y-4"),
						ui.FormField(ui.FormFieldProps{
							Label:       "Current Password",
							Id:          "current_password",
							Name:        "current_password",
							Type:        "password",
							Placeholder: "••••••••••••••••",
							Icon:        ui.IconLock,
							Required:    true,
							Error:       data.CurrentPasswordError,
						}),
						ui.FormField(ui.FormFieldProps{
							Label:       "New Password",
							Id:          "new_password",
							Name:        "new_password",
							Type:        "password",
							Placeholder: "••••••••••••••••",
							Icon:        ui.IconLock,
							Required:    true,
							Error:       data.NewPasswordError,
						}),
						ui.FormField(ui.FormFieldProps{
							Label:       "Confirm New Password",
							Id:          "confirm_password",
							Name:        "confirm_password",
							Type:        "password",
							Placeholder: "••••••••••••••••",
							Icon:        ui.IconLock,
							Required:    true,
							Error:       data.ConfirmPasswordError,
						}),
						html.Div(
							attr.Class("flex justify-end"),
							ui.Button(
								ui.ButtonProps{
									Variant: ui.ButtonPrimary,
									Type:    "submit",
								},
								html.Text("Update Password"),
							),
						),
					),
				),
			}),

			// Access Tokens Card
			html.Div(
				attr.Id("access-tokens"),
				ui.Card(ui.CardProps{
					Title:       "Access Tokens",
					Description: "Personal access tokens can be used as passwords for Git operations",
					Content: html.Div(
						attr.Class("space-y-4"),
						html.If(data.AccessTokenSuccess != "", html.Div(
							attr.Class("p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200 text-sm"),
							html.Text(data.AccessTokenSuccess),
						)),
						html.If(data.AccessTokenError != "", html.Div(
							attr.Class("p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 text-sm"),
							html.Text(data.AccessTokenError),
						)),
						html.If(data.NewAccessToken != "", html.Div(
							attr.Class("p-4 rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800"),
							html.Div(
								attr.Class("flex items-start gap-3"),
								html.Div(
									attr.Class("flex-shrink-0 mt-0.5"),
									ui.SVGIcon(ui.IconAlertCircle, "text-yellow-600 dark:text-yellow-400"),
								),
								html.Div(
									attr.Class("flex-1 space-y-3"),
									html.P(
										attr.Class("text-sm font-medium text-yellow-800 dark:text-yellow-200"),
										html.Text("Make sure to copy your access token now. You won't be able to see it again!"),
									),
									html.Div(
										attr.Class("space-y-2"),
										html.Label(
											attr.For("new-token-value"),
											attr.Class("label block"),
											html.Text("Your New Access Token"),
										),
										html.Div(
											attr.Class("flex gap-2"),
											html.Input(
												attr.Type("text"),
												attr.Readonly(),
												attr.Value(data.NewAccessToken),
												attr.Id("new-token-value"),
												attr.Class("input font-mono text-sm flex-1"),
											),
											html.Element("button",
												attr.Type("button"),
												attr.Onclick("copyNewToken()"),
												attr.Id("copy-token-btn"),
												attr.Class("btn-icon-outline cursor-pointer"),
												attr.DataTooltip("Copy to clipboard"),
												attr.DataSide("top"),
												ui.SVGIcon(ui.IconCopy, "size-4"),
												ui.SVGIcon(ui.IconCheck, "size-4 hidden"),
											),
										),
									),
								),
							),
						)),
						html.Form(
							attr.Method("POST"),
							attr.Action("/settings/access-tokens"),
							attr.Class("space-y-4"),
							ui.FormField(ui.FormFieldProps{
								Label:       "Token Name",
								Id:          "token_name",
								Name:        "name",
								Type:        "text",
								Placeholder: "My Token",
								Icon:        ui.IconLock,
								Required:    true,
							}),
							html.Div(
								attr.Class("flex justify-end"),
								ui.Button(
									ui.ButtonProps{
										Variant: ui.ButtonPrimary,
										Type:    "submit",
									},
									html.Text("Generate Token"),
								),
							),
						),
						html.If(len(data.AccessTokens) > 0, html.Div(
							attr.Class("space-y-2 mt-6"),
							html.H3(
								attr.Class("text-sm font-semibold text-foreground mb-3"),
								html.Text("Active Tokens"),
							),
							html.Div(
								attr.Class("space-y-2"),
								html.Group(accessTokenList(data.AccessTokens)...),
							),
						)),
					),
				}),
			),

			// JavaScript for username change confirmation and token copying
			html.Element("script",
				html.Text(`
					(function() {
						const form = document.getElementById('general-settings-form');
						if (form) {
							form.addEventListener('submit', function(e) {
								const originalUsername = form.getAttribute('data-original-username');
								const currentUsername = document.getElementById('username').value;

								if (originalUsername !== currentUsername) {
									const confirmed = window.confirm(
										'Are you sure you want to change your username from "@' + originalUsername + '" to "@' + currentUsername + '"?\n\n' +
										'This may affect your profile URL and repository access.'
									);

									if (!confirmed) {
										e.preventDefault();
										return false;
									}
								}
							});
						}

						// Copy token to clipboard
						window.copyNewToken = function() {
							const input = document.getElementById('new-token-value');
							const button = document.getElementById('copy-token-btn');
							const copyIcon = button.querySelector('svg:nth-child(1)');
							const checkIcon = button.querySelector('svg:nth-child(2)');
							const tokenValue = input.value;

							if (navigator.clipboard && navigator.clipboard.writeText) {
								navigator.clipboard.writeText(tokenValue).then(function() {
									showTokenCopySuccess();
								}).catch(function(err) {
									console.error('Failed to copy:', err);
									fallbackCopy();
								});
							} else {
								fallbackCopy();
							}

							function fallbackCopy() {
								input.select();
								input.setSelectionRange(0, 99999);
								try {
									document.execCommand('copy');
									showTokenCopySuccess();
								} catch (err) {
									console.error('Fallback copy failed:', err);
								}
							}

							function showTokenCopySuccess() {
								button.setAttribute('data-tooltip', 'Copied!');
								copyIcon.classList.add('hidden');
								checkIcon.classList.remove('hidden');
								setTimeout(function() {
									button.setAttribute('data-tooltip', 'Copy to clipboard');
									copyIcon.classList.remove('hidden');
									checkIcon.classList.add('hidden');
								}, 2000);

								// Show toast notification
								document.dispatchEvent(new CustomEvent('basecoat:toast', {
									detail: {
										config: {
											category: 'success',
											title: 'Access token copied!',
											description: 'Your access token has been copied to your clipboard.',
											duration: 2000
										}
									}
								}));
							}
						};

						// Handle token deletion confirmation
						document.querySelectorAll('[data-delete-token]').forEach(function(btn) {
							btn.addEventListener('click', function(e) {
								const tokenName = btn.getAttribute('data-token-name');
								if (!confirm('Are you sure you want to delete the token "' + tokenName + '"? This action cannot be undone.')) {
									e.preventDefault();
								}
							});
						});
					})();
				`),
			),
		),
	)
}

func accessTokenList(tokens []*models.AccessToken) []html.Node {
	if tokens == nil || len(tokens) == 0 {
		return []html.Node{}
	}

	nodes := make([]html.Node, 0, len(tokens))
	for _, token := range tokens {
		if token != nil {
			nodes = append(nodes, accessTokenItem(token))
		}
	}
	return nodes
}

func accessTokenItem(token *models.AccessToken) html.Node {
	if token == nil {
		return html.Div()
	}

	return html.Div(
		attr.Class("flex items-center justify-between p-3 bg-muted rounded-lg"),
		html.Div(
			attr.Class("flex-1"),
			html.Div(
				attr.Class("font-medium text-sm text-foreground"),
				html.Text(token.Name),
			),
			html.Div(
				attr.Class("text-xs text-muted-foreground mt-1"),
				html.Text("Created "+formatTimestamp(token.CreatedAt)),
				html.IfElsef(
					token.LastUsedAt != nil,
					func() html.Node {
						return html.Text(" • Last used " + formatTimestamp(*token.LastUsedAt))
					},
					func() html.Node {
						return html.Group()
					},
				),
			),
		),
		html.Form(
			attr.Method("POST"),
			attr.Action("/settings/access-tokens/"+fmt.Sprintf("%d", token.ID)+"/delete"),
			attr.Class("inline"),
			html.Element("button",
				attr.Type("submit"),
				attr.Class("btn-icon-ghost text-destructive hover:text-destructive"),
				attr.Attribute{Key: "data-delete-token", Value: "true"},
				attr.Attribute{Key: "data-token-name", Value: token.Name},
				attr.DataTooltip("Delete token"),
				attr.DataSide("left"),
				ui.SVGIcon(ui.IconTrash, "text-destructive"),
			),
		),
	)
}

func formatTimestamp(timestamp int64) string {
	// Convert Unix timestamp to a human-readable format
	// For now, just return a simple representation
	t := time.Unix(timestamp, 0)
	now := time.Now()

	diff := now.Sub(t)

	if diff < time.Minute {
		return "just now"
	} else if diff < time.Hour {
		mins := int(diff.Minutes())
		if mins == 1 {
			return "1 minute ago"
		}
		return fmt.Sprintf("%d minutes ago", mins)
	} else if diff < 24*time.Hour {
		hours := int(diff.Hours())
		if hours == 1 {
			return "1 hour ago"
		}
		return fmt.Sprintf("%d hours ago", hours)
	} else if diff < 7*24*time.Hour {
		days := int(diff.Hours() / 24)
		if days == 1 {
			return "yesterday"
		}
		return fmt.Sprintf("%d days ago", days)
	} else if diff < 30*24*time.Hour {
		weeks := int(diff.Hours() / 24 / 7)
		if weeks == 1 {
			return "1 week ago"
		}
		return fmt.Sprintf("%d weeks ago", weeks)
	} else if diff < 365*24*time.Hour {
		months := int(diff.Hours() / 24 / 30)
		if months == 1 {
			return "1 month ago"
		}
		return fmt.Sprintf("%d months ago", months)
	} else {
		years := int(diff.Hours() / 24 / 365)
		if years == 1 {
			return "1 year ago"
		}
		return fmt.Sprintf("%d years ago", years)
	}
}