diff --git a/addy.go b/addy.go index c2878e1..7a01e9a 100644 --- a/addy.go +++ b/addy.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "os" + "slices" "strings" ) @@ -134,14 +135,7 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) { http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable) return } - domainAllowed := false - for _, d := range allowedAliasDomains { - if body.Domain == d { - domainAllowed = true - break - } - } - if !domainAllowed { + if !slices.Contains(allowedAliasDomains, body.Domain) { http.Error(w, "Domain not allowed", http.StatusBadRequest) return } diff --git a/change.go b/change.go index 0a9e7e6..d915aba 100644 --- a/change.go +++ b/change.go @@ -33,7 +33,7 @@ func checkPasswdConstraint(password string) error { func changePassword(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && !changeLimiter.Allow(remoteIP(r)) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]any{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) return } @@ -43,25 +43,25 @@ func changePassword(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken}) + displayTmpl(w, "change.html", map[string]any{"csrf_token": csrfToken}) return } if !validateCSRF(r) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) return } if !validateAltcha(r) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) return } renderError := func(status int, msg string) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken}) + displayTmplError(w, status, "change.html", map[string]any{"error": msg, "csrf_token": csrfToken}) } // Check the two new passwords are identical diff --git a/login.go b/login.go index 44ffe66..63af618 100644 --- a/login.go +++ b/login.go @@ -2,8 +2,6 @@ package main import ( "fmt" - "html" - "html/template" "log" "net/http" "net/url" @@ -12,6 +10,69 @@ import ( "github.com/go-ldap/ldap/v3" ) +type profileField struct { + Name string + Label string + Value string +} + +type profileAlias struct { + Value string + URLSafe string + ElemID string + Token string +} + +var ldapLabels = map[string]string{ + "cn": "Full name", + "uid": "Username", + "givenName": "First name", + "sn": "Last name", + "displayName": "Display name", + "telephoneNumber": "Phone", + "mobile": "Mobile", + "employeeNumber": "Employee ID", + "o": "Organization", + "ou": "Department", + "title": "Title", + "description": "Description", + "labeledURI": "Website", +} + +// ldapSkip lists attributes that should never be shown to the user. +var ldapSkip = map[string]bool{ + "userPassword": true, + "krbPrincipalKey": true, + "objectClass": true, + "entryUUID": true, + "entryDN": true, + "structuralObjectClass": true, + "hasSubordinates": true, + "krbExtraData": true, +} + +// isGeneratedAlias returns true for auto-generated alias local parts: +// exactly 10 characters and containing at least one digit or uppercase letter, +// which distinguishes them from plain words like "postmaster" or "abonnement". +func isGeneratedAlias(local string) bool { + if len(local) != 10 { + return false + } + for _, c := range local { + if c >= '0' && c <= '9' || c >= 'A' && c <= 'Z' { + return true + } + } + return false +} + +func ldapLabel(name string) string { + if l, ok := ldapLabels[name]; ok { + return l + } + return name +} + func login(login string, password string) ([]*ldap.EntryAttribute, error) { conn, err := myLDAP.Connect() if err != nil || conn == nil { @@ -44,47 +105,71 @@ func login(login string, password string) ([]*ldap.EntryAttribute, error) { func tryLogin(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { - displayTmpl(w, "login.html", map[string]interface{}{}) + displayTmpl(w, "login.html", map[string]any{}) return } if !authLimiter.Allow(remoteIP(r)) { - displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."}) + displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]any{"error": "Too many login attempts. Please try again later."}) return } if !validateAltcha(r) { - displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + displayTmplError(w, http.StatusForbidden, "login.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."}) return } - if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { + loginName := r.PostFormValue("login") + entries, err := login(loginName, r.PostFormValue("password")) + if err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) - } else { - apiToken := AddyAPIToken(r.PostFormValue("login")) + displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]any{"error": err.Error()}) + return + } - cnt := "

To use our Addy.io compatible API, use the following token: " + html.EscapeString(apiToken) + "

")}) } + + displayTmpl(w, "profile.html", map[string]any{ + "login": loginName, + "fields": fields, + "emails": emails, + "aliases": aliases, + "api_token": apiToken, + "card_wide": true, + }) } func httpBasicAuth(w http.ResponseWriter, r *http.Request) { @@ -107,7 +192,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) { for _, e := range entries { for _, v := range e.Values { if e.Name != "userPassword" { - w.Write([]byte(fmt.Sprintf("%s: %s", e.Name, v))) + fmt.Fprintf(w, "%s: %s", e.Name, v) } } } diff --git a/lost.go b/lost.go index 1e4c9d3..7afe093 100644 --- a/lost.go +++ b/lost.go @@ -88,7 +88,7 @@ func lostPasswordToken(conn *LDAPConn, login string) (string, string, error) { func lostPassword(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" && !lostLimiter.Allow(remoteIP(r)) { - displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"error": "Too many requests. Please try again later."}) + displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]any{"error": "Too many requests. Please try again later."}) return } @@ -98,17 +98,17 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken}) + displayTmpl(w, "lost.html", map[string]any{"csrf_token": csrfToken}) return } if !validateCSRF(r) { - displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."}) + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again."}) return } if !validateAltcha(r) { - displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."}) return } @@ -116,7 +116,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { conn, err := myLDAP.Connect() if err != nil || conn == nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to process your request. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to process your request. Please try again later."}) return } @@ -167,7 +167,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { s, err = d.Dial() if err != nil { log.Println("Unable to connect to email server: " + err.Error()) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."}) return } } else { @@ -202,7 +202,7 @@ func lostPassword(w http.ResponseWriter, r *http.Request) { if err := gomail.Send(s, m); err != nil { log.Println("Unable to send email: " + err.Error()) - displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]any{"error": "Unable to send password recovery email. Please try again later."}) return } diff --git a/main.go b/main.go index 36ab61d..32a1124 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "encoding/json" "flag" "fmt" - "io/ioutil" + "io" "log" "net/http" "net/url" @@ -19,6 +19,8 @@ import ( var myPublicURL = "https://ldap.nemunai.re" var devMode bool +var brandName = "chldapasswd" +var brandLogo = "" // dockerRegistrySecret is required for X-Special-Auth anonymous access. // If empty, the feature is disabled. @@ -82,10 +84,20 @@ func main() { var configfile = flag.String("config", "", "path to the configuration file") var publicURL = flag.String("public-url", myPublicURL, "Public base URL used in password reset emails") var dev = flag.Bool("dev", false, "Development mode: disables HSTS and cookie Secure flag for local HTTP testing") + var bname = flag.String("brand-name", "chldapasswd", "Brand name displayed in the UI") + var blogo = flag.String("brand-logo", "", "URL of brand logo displayed in the UI (added to CSP img-src)") flag.Parse() myPublicURL = *publicURL devMode = *dev + brandName = *bname + brandLogo = *blogo + if val, ok := os.LookupEnv("BRAND_NAME"); ok { + brandName = val + } + if val, ok := os.LookupEnv("BRAND_LOGO"); ok { + brandLogo = val + } if devMode { log.Println("WARNING: running in development mode — security features relaxed, do not use in production") } @@ -104,7 +116,7 @@ func main() { if configfile != nil && *configfile != "" { if fd, err := os.Open(*configfile); err != nil { log.Fatal(err) - } else if cnt, err := ioutil.ReadAll(fd); err != nil { + } else if cnt, err := io.ReadAll(fd); err != nil { log.Fatal(err) } else if err := json.Unmarshal(cnt, &myLDAP); err != nil { log.Fatal(err) @@ -137,7 +149,7 @@ func main() { if val, ok := os.LookupEnv("LDAP_SERVICE_PASSWORD_FILE"); ok { if fd, err := os.Open(val); err != nil { log.Fatal(err) - } else if cnt, err := ioutil.ReadAll(fd); err != nil { + } else if cnt, err := io.ReadAll(fd); err != nil { log.Fatal(err) } else { myLDAP.ServicePassword = string(cnt) @@ -220,6 +232,7 @@ func main() { // Register handlers http.HandleFunc(fmt.Sprintf("GET %s/altcha.min.js", *baseURL), serveAltchaJS) + http.HandleFunc(fmt.Sprintf("GET %s/style.css", *baseURL), serveStyleCSS) http.HandleFunc(fmt.Sprintf("GET %s/altcha-challenge", *baseURL), serveAltchaChallenge) http.HandleFunc(fmt.Sprintf("%s/{$}", *baseURL), changePassword) http.HandleFunc(fmt.Sprintf("POST %s/api/v1/aliases", *baseURL), addyAliasAPI) @@ -239,7 +252,8 @@ func main() { go func() { log.Fatal(srv.ListenAndServe()) }() - log.Println(fmt.Sprintf("Ready, listening on %s", *bind)) + log.Printf("Using LDAP server at %s:%d (baseDN: %s)", myLDAP.Host, myLDAP.Port, myLDAP.BaseDN) + log.Printf("Ready, listening on %s", *bind) // Wait shutdown signal <-interrupt diff --git a/reset.go b/reset.go index a37f99d..a65c27c 100644 --- a/reset.go +++ b/reset.go @@ -16,7 +16,7 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { return } - base := map[string]interface{}{ + base := map[string]any{ "login": r.URL.Query().Get("l"), "token": r.URL.Query().Get("t"), } diff --git a/static.go b/static.go index 4946083..6c5f316 100644 --- a/static.go +++ b/static.go @@ -5,6 +5,8 @@ import ( "html/template" "log" "net/http" + "net/url" + "strings" ) func securityHeaders(next http.Handler) http.Handler { @@ -12,7 +14,16 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline' https://stackpath.bootstrapcdn.com; style-src https://stackpath.bootstrapcdn.com; img-src 'self'; font-src https://stackpath.bootstrapcdn.com") + + imgSrc := "'self' data:" + if strings.HasPrefix(brandLogo, "http://") || strings.HasPrefix(brandLogo, "https://") { + if u, err := url.Parse(brandLogo); err == nil { + imgSrc += " " + u.Scheme + "://" + u.Host + } + } + csp := "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'unsafe-inline'; style-src 'self' 'sha256-W6z8OR2iqpPyNGe72eRXH58H75H3UVJDuwHoKA6pX98='; img-src " + imgSrc + "; worker-src blob:" + w.Header().Set("Content-Security-Policy", csp) + if !devMode { w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") } @@ -23,7 +34,26 @@ func securityHeaders(next http.Handler) http.Handler { //go:embed all:static var assets embed.FS -func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{}) { +func serveStyleCSS(w http.ResponseWriter, r *http.Request) { + data, err := assets.ReadFile("static/style.css") + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Write(data) +} + +func displayTmpl(w http.ResponseWriter, page string, vars map[string]any) { + if vars == nil { + vars = map[string]any{} + } + vars["brand_name"] = brandName + if brandLogo != "" { + vars["brand_logo"] = brandLogo + } + data, err := assets.ReadFile("static/" + page) if err != nil { log.Fatalf("Unable to find %q: %s", page, err.Error()) @@ -45,7 +75,7 @@ func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{} tpl.ExecuteTemplate(w, "page", vars) } -func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]interface{}) { +func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]any) { w.WriteHeader(statusCode) displayTmpl(w, page, vars) } @@ -58,5 +88,5 @@ func displayMsg(w http.ResponseWriter, msg string, statusCode int) { label = "message" } - displayTmpl(w, "message.html", map[string]interface{}{label: msg}) + displayTmpl(w, "message.html", map[string]any{label: msg}) } diff --git a/static/change.html b/static/change.html index 424b171..5b6fcaf 100644 --- a/static/change.html +++ b/static/change.html @@ -1,49 +1,46 @@ -{{template "header"}} -

Change your password Fill the following fields!

+{{template "header" .}} +

Change your password

+

Fill the following fields!

- {{if .error}}{{end}} + {{if .error}}{{end}} -
+
-
+
-
- -
+
-
+
-
- -
+
-
+
-
- -
+
-
+
- - Forgot your password? +
+ + Forgot your password? +
-{{template "footer"}} +{{template "footer" .}} diff --git a/static/footer.html b/static/footer.html index e7c1c45..2d6e1aa 100644 --- a/static/footer.html +++ b/static/footer.html @@ -1,5 +1,4 @@ {{define "footer"}} -
diff --git a/static/header.html b/static/header.html index f86b55b..6591d91 100644 --- a/static/header.html +++ b/static/header.html @@ -1,18 +1,17 @@ {{define "header"}} - - - - - - - - - nemunai.re password change - - - -
-
+ + + + {{if .brand_name}}{{.brand_name}} - {{end}}Password management + + + + +
+
+ {{if .brand_logo}}{{end}} + {{if .brand_name}}{{.brand_name}}{{else}}Password management{{end}} +
{{end}} diff --git a/static/login.html b/static/login.html index ec0678a..10ffa71 100644 --- a/static/login.html +++ b/static/login.html @@ -1,18 +1,21 @@ -{{template "header"}} -

Sign in Don't have an account? Create one!

+{{template "header" .}} +

Sign in

+

Don't have an account? Create one!

- {{if .error}}{{end}} -
+ {{if .error}}{{end}} +
-
+
-
+
- - Forgot your password? - -{{template "footer"}} +
+ + Forgot your password? +
+ +{{template "footer" .}} diff --git a/static/lost.html b/static/lost.html index bba8878..2044c1c 100644 --- a/static/lost.html +++ b/static/lost.html @@ -1,16 +1,19 @@ -{{template "header"}} -

Forgot your password? We'll send you a link by e-mail to reset it!

+{{template "header" .}} +

Forgot your password?

+

We'll send you a link by e-mail to reset it!

- {{if .error}}{{end}} + {{if .error}}{{end}} -
+
-
+
- - Just want to change your password? +
+ + Just want to change your password? +
-{{template "footer"}} +{{template "footer" .}} diff --git a/static/message.html b/static/message.html index f377b6e..081b18f 100644 --- a/static/message.html +++ b/static/message.html @@ -1,5 +1,7 @@ -{{template "header"}} - {{if .message}}
{{.message}}
{{end}} - {{if .error}}
{{.error}}
{{end}} - {{if .details}}

{{.details}}

{{end}} -{{template "footer"}} +{{template "header" .}} +
+ {{if .message}}
{{.message}}
{{end}} + {{if .error}}
{{.error}}
{{end}} + {{if .details}}

{{.details}}

{{end}} +
+{{template "footer" .}} diff --git a/static/profile.html b/static/profile.html new file mode 100644 index 0000000..680c197 --- /dev/null +++ b/static/profile.html @@ -0,0 +1,76 @@ +{{template "header" .}} +

Welcome, {{.login}}

+ + + +
+ {{if .fields}} + + {{range .fields}} + + + + + {{end}} +
{{.Label}}{{.Value}}
+ {{else}} +

No account information available.

+ {{end}} +
+ + {{if or .emails .aliases}} + + {{end}} + + + + +{{template "footer" .}} diff --git a/static/reset.html b/static/reset.html index 382ab00..dee828f 100644 --- a/static/reset.html +++ b/static/reset.html @@ -1,23 +1,26 @@ -{{template "header"}} -

Forgot your password? Define a new one!

+{{template "header" .}} +

Forgot your password?

+

Define a new one!

- {{if .error}}{{end}} + {{if .error}}{{end}} -
+
-
+
-
+
-
+
- +
+ +
-{{template "footer"}} +{{template "footer" .}} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..2f24d06 --- /dev/null +++ b/static/style.css @@ -0,0 +1,479 @@ +/* ============================================================ + CSS custom properties + ============================================================ */ +:root { + --bg: #f4f6f9; + --card-bg: #ffffff; + --card-shadow: 0 4px 24px rgba(0,0,0,.10); + --text: #1a1d23; + --text-muted: #6b7280; + --border: #d1d5db; + --input-bg: #ffffff; + --input-focus-border: #4f46e5; + --input-focus-shadow: 0 0 0 3px rgba(79,70,229,.15); + --btn-primary-bg: #4f46e5; + --btn-primary-hover: #4338ca; + --btn-secondary-bg: #f3f4f6; + --btn-secondary-hover: #e5e7eb; + --btn-secondary-text: #374151; + --alert-error-bg: #fef2f2; + --alert-error-border: #fca5a5; + --alert-error-text: #991b1b; + --alert-success-bg: #f0fdf4; + --alert-success-border: #86efac; + --alert-success-text: #166534; + --brand-border: #e5e7eb; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f1117; + --card-bg: #1e2130; + --card-shadow: 0 4px 24px rgba(0,0,0,.40); + --text: #e5e7eb; + --text-muted: #9ca3af; + --border: #374151; + --input-bg: #111827; + --input-focus-border: #6366f1; + --input-focus-shadow: 0 0 0 3px rgba(99,102,241,.20); + --btn-primary-bg: #6366f1; + --btn-primary-hover: #4f46e5; + --btn-secondary-bg: #374151; + --btn-secondary-hover: #4b5563; + --btn-secondary-text: #d1d5db; + --alert-error-bg: #3b1515; + --alert-error-border: #7f1d1d; + --alert-error-text: #fca5a5; + --alert-success-bg: #052e16; + --alert-success-border: #14532d; + --alert-success-text: #86efac; + --brand-border: #374151; + } +} + +/* ============================================================ + Base + ============================================================ */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 1.6; + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +/* ============================================================ + Card + ============================================================ */ +.card { + background: var(--card-bg); + border-radius: 12px; + box-shadow: var(--card-shadow); + padding: 2rem 2.5rem; + width: 100%; + max-width: 440px; +} + +/* ============================================================ + Brand + ============================================================ */ +.brand { + display: flex; + align-items: center; + gap: 0.625rem; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--brand-border); +} + +.brand-logo { + height: 2rem; + width: auto; + object-fit: contain; +} + +.brand-name { + font-size: 1.125rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--text); +} + +/* ============================================================ + Page title / subtitle + ============================================================ */ +.page-title { + font-size: 1.375rem; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 0.25rem; +} + +.page-subtitle { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 1.5rem; +} + +.page-subtitle a { + color: var(--btn-primary-bg); + text-decoration: none; +} + +.page-subtitle a:hover { + text-decoration: underline; +} + +/* ============================================================ + Alerts + ============================================================ */ +.alert { + border-radius: 8px; + padding: 0.75rem 1rem; + font-size: 0.9rem; + margin-bottom: 1rem; + border: 1px solid transparent; +} + +.alert-error { + background: var(--alert-error-bg); + border-color: var(--alert-error-border); + color: var(--alert-error-text); +} + +.alert-success { + background: var(--alert-success-bg); + border-color: var(--alert-success-border); + color: var(--alert-success-text); +} + +/* ============================================================ + Forms + ============================================================ */ +.form-field { + margin-bottom: 1rem; +} + +.form-control { + display: block; + width: 100%; + padding: 0.625rem 0.875rem; + font-size: 0.9375rem; + font-family: inherit; + background: var(--input-bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + transition: border-color .15s, box-shadow .15s; + outline: none; + -webkit-appearance: none; +} + +.form-control:focus { + border-color: var(--input-focus-border); + box-shadow: var(--input-focus-shadow); +} + +.form-control::placeholder { + color: var(--text-muted); +} + +.form-control:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Input group with toggle button */ +.input-group { + position: relative; +} + +.input-group .form-control { + padding-right: 3rem; +} + +.btn-toggle-password { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 2.75rem; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-muted); + border-radius: 0 8px 8px 0; + transition: color .15s; + padding: 0; +} + +.btn-toggle-password:hover { + color: var(--text); +} + +/* ============================================================ + Buttons + ============================================================ */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.625rem 1.25rem; + font-size: 0.9375rem; + font-family: inherit; + font-weight: 500; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; + text-decoration: none; + transition: background .15s, color .15s, border-color .15s, box-shadow .15s; + white-space: nowrap; +} + +.btn-primary { + background: var(--btn-primary-bg); + color: #ffffff; + border-color: var(--btn-primary-bg); +} + +.btn-primary:hover { + background: var(--btn-primary-hover); + border-color: var(--btn-primary-hover); +} + +.btn-secondary { + background: var(--btn-secondary-bg); + color: var(--btn-secondary-text); + border-color: var(--border); +} + +.btn-secondary:hover { + background: var(--btn-secondary-hover); +} + +.btn-group { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-top: 1.5rem; +} + +/* ============================================================ + Message page + ============================================================ */ +.message-page { + padding: 0.5rem 0; +} + +.details-text { + color: var(--text-muted); + font-size: 0.9rem; + margin-top: 0.75rem; +} + +/* ============================================================ + altcha widget + ============================================================ */ +.form-field altcha-widget { + display: block; +} + +/* ============================================================ + Card wide (profile page) + ============================================================ */ +.card-wide { + max-width: 680px; +} + +/* ============================================================ + Tabs + ============================================================ */ +.tabs { + display: flex; + gap: 0.25rem; + border-bottom: 2px solid var(--border); + margin-bottom: 1.5rem; +} + +.tab-btn { + padding: 0.5rem 1rem; + font-size: 0.9rem; + font-family: inherit; + font-weight: 500; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + cursor: pointer; + color: var(--text-muted); + border-radius: 4px 4px 0 0; + transition: color .15s, border-color .15s; +} + +.tab-btn:hover { + color: var(--text); +} + +.tab-btn.active { + color: var(--btn-primary-bg); + border-bottom-color: var(--btn-primary-bg); +} + +.tab-panel.hidden { + display: none; +} + +/* ============================================================ + Profile: Account tab + ============================================================ */ +.info-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.info-table th, +.info-table td { + padding: 0.5rem 0.625rem; + text-align: left; + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.info-table th { + width: 38%; + color: var(--text-muted); + font-weight: 500; + white-space: nowrap; +} + +.info-table tr:last-child th, +.info-table tr:last-child td { + border-bottom: none; +} + +.section-empty { + color: var(--text-muted); + font-size: 0.9rem; +} + +/* ============================================================ + Profile: Email & Aliases tab + ============================================================ */ +.section-title { + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.625rem; +} + +.section-title + .section-title, +.email-list + .section-title, +.alias-list + .section-title { + margin-top: 1.25rem; +} + +.email-list { + list-style: none; + margin-bottom: 0.5rem; +} + +.email-list li { + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.email-list li:last-child { + border-bottom: none; +} + +.alias-list { + list-style: none; +} + +.alias-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.4rem 0; + border-bottom: 1px solid var(--border); +} + +.alias-item:last-child { + border-bottom: none; +} + +.alias-value { + font-size: 0.875rem; + word-break: break-all; +} + +/* ============================================================ + Profile: API tab + ============================================================ */ +.api-token-box { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.75rem 1rem; + word-break: break-all; + margin: 0.75rem 0; + user-select: all; +} + +.section-desc { + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.section-desc code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + background: var(--btn-secondary-bg); + padding: 0.1em 0.35em; + border-radius: 4px; + color: var(--text); +} + +/* ============================================================ + Button variants + ============================================================ */ +.btn-danger { + background: #dc2626; + color: #ffffff; + border-color: #dc2626; +} + +.btn-danger:hover { + background: #b91c1c; + border-color: #b91c1c; +} + +.btn-sm { + padding: 0.3rem 0.75rem; + font-size: 0.8125rem; + border-radius: 6px; + white-space: nowrap; + flex-shrink: 0; +}