diff --git a/addy.go b/addy.go index 7a01e9a..c2878e1 100644 --- a/addy.go +++ b/addy.go @@ -12,7 +12,6 @@ import ( "log" "net/http" "os" - "slices" "strings" ) @@ -135,7 +134,14 @@ func addyAliasAPI(w http.ResponseWriter, r *http.Request) { http.Error(w, "Alias creation is not configured", http.StatusServiceUnavailable) return } - if !slices.Contains(allowedAliasDomains, body.Domain) { + domainAllowed := false + for _, d := range allowedAliasDomains { + if body.Domain == d { + domainAllowed = true + break + } + } + if !domainAllowed { http.Error(w, "Domain not allowed", http.StatusBadRequest) return } diff --git a/change.go b/change.go index d915aba..0a9e7e6 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]any{"error": "Too many requests. Please try again later.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusTooManyRequests, "change.html", map[string]interface{}{"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]any{"csrf_token": csrfToken}) + displayTmpl(w, "change.html", map[string]interface{}{"csrf_token": csrfToken}) return } if !validateCSRF(r) { csrfToken, _ := setCSRFToken(w) - displayTmplError(w, http.StatusForbidden, "change.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"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]any{"error": "Invalid or missing altcha response. Please try again.", "csrf_token": csrfToken}) + displayTmplError(w, http.StatusForbidden, "change.html", map[string]interface{}{"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]any{"error": msg, "csrf_token": csrfToken}) + displayTmplError(w, status, "change.html", map[string]interface{}{"error": msg, "csrf_token": csrfToken}) } // Check the two new passwords are identical diff --git a/login.go b/login.go index 63af618..44ffe66 100644 --- a/login.go +++ b/login.go @@ -2,6 +2,8 @@ package main import ( "fmt" + "html" + "html/template" "log" "net/http" "net/url" @@ -10,69 +12,6 @@ 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 { @@ -105,71 +44,47 @@ 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]any{}) + displayTmpl(w, "login.html", map[string]interface{}{}) return } if !authLimiter.Allow(remoteIP(r)) { - displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]any{"error": "Too many login attempts. Please try again later."}) + displayTmplError(w, http.StatusTooManyRequests, "login.html", map[string]interface{}{"error": "Too many login attempts. Please try again later."}) return } if !validateAltcha(r) { - displayTmplError(w, http.StatusForbidden, "login.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."}) + displayTmplError(w, http.StatusForbidden, "login.html", map[string]interface{}{"error": "Invalid or missing altcha response. Please try again."}) return } - loginName := r.PostFormValue("login") - entries, err := login(loginName, r.PostFormValue("password")) - if err != nil { + if entries, err := login(r.PostFormValue("login"), r.PostFormValue("password")); err != nil { log.Println(err) - displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]any{"error": err.Error()}) - return - } + displayTmplError(w, http.StatusInternalServerError, "login.html", map[string]interface{}{"error": err.Error()}) + } else { + apiToken := AddyAPIToken(r.PostFormValue("login")) - apiToken := AddyAPIToken(loginName) - var fields []profileField - var emails []string - var aliases []profileAlias - aliasIdx := 0 - - for _, e := range entries { - if ldapSkip[e.Name] { - continue - } - for _, v := range e.Values { - switch { - case e.Name == "mail": - emails = append(emails, v) - case e.Name == "mailAlias" && isGeneratedAlias(strings.SplitN(v, "@", 2)[0]): - elemID := fmt.Sprintf("alias-%d", aliasIdx) - aliasIdx++ - aliases = append(aliases, profileAlias{ - Value: v, - URLSafe: url.PathEscape(v), - ElemID: elemID, - Token: apiToken, - }) - case e.Name == "mailAlias": - emails = append(emails, v) - default: - fields = append(fields, profileField{ - Name: e.Name, - Label: ldapLabel(e.Name), - Value: v, - }) + 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) { @@ -192,7 +107,7 @@ func httpBasicAuth(w http.ResponseWriter, r *http.Request) { for _, e := range entries { for _, v := range e.Values { if e.Name != "userPassword" { - fmt.Fprintf(w, "%s: %s", e.Name, v) + w.Write([]byte(fmt.Sprintf("%s: %s", e.Name, v))) } } } diff --git a/lost.go b/lost.go index 7afe093..1e4c9d3 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]any{"error": "Too many requests. Please try again later."}) + displayTmplError(w, http.StatusTooManyRequests, "lost.html", map[string]interface{}{"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]any{"csrf_token": csrfToken}) + displayTmpl(w, "lost.html", map[string]interface{}{"csrf_token": csrfToken}) return } if !validateCSRF(r) { - displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing CSRF token. Please try again."}) + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"error": "Invalid or missing CSRF token. Please try again."}) return } if !validateAltcha(r) { - displayTmplError(w, http.StatusForbidden, "lost.html", map[string]any{"error": "Invalid or missing altcha response. Please try again."}) + displayTmplError(w, http.StatusForbidden, "lost.html", map[string]interface{}{"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]any{"error": "Unable to process your request. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"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]any{"error": "Unable to send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"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]any{"error": "Unable to send password recovery email. Please try again later."}) + displayTmplError(w, http.StatusInternalServerError, "lost.html", map[string]interface{}{"error": "Unable to send password recovery email. Please try again later."}) return } diff --git a/main.go b/main.go index 32a1124..36ab61d 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "encoding/json" "flag" "fmt" - "io" + "io/ioutil" "log" "net/http" "net/url" @@ -19,8 +19,6 @@ 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. @@ -84,20 +82,10 @@ 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") } @@ -116,7 +104,7 @@ func main() { if configfile != nil && *configfile != "" { if fd, err := os.Open(*configfile); err != nil { log.Fatal(err) - } else if cnt, err := io.ReadAll(fd); err != nil { + } else if cnt, err := ioutil.ReadAll(fd); err != nil { log.Fatal(err) } else if err := json.Unmarshal(cnt, &myLDAP); err != nil { log.Fatal(err) @@ -149,7 +137,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 := io.ReadAll(fd); err != nil { + } else if cnt, err := ioutil.ReadAll(fd); err != nil { log.Fatal(err) } else { myLDAP.ServicePassword = string(cnt) @@ -232,7 +220,6 @@ 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) @@ -252,8 +239,7 @@ func main() { go func() { log.Fatal(srv.ListenAndServe()) }() - log.Printf("Using LDAP server at %s:%d (baseDN: %s)", myLDAP.Host, myLDAP.Port, myLDAP.BaseDN) - log.Printf("Ready, listening on %s", *bind) + log.Println(fmt.Sprintf("Ready, listening on %s", *bind)) // Wait shutdown signal <-interrupt diff --git a/reset.go b/reset.go index a65c27c..a37f99d 100644 --- a/reset.go +++ b/reset.go @@ -16,7 +16,7 @@ func resetPassword(w http.ResponseWriter, r *http.Request) { return } - base := map[string]any{ + base := map[string]interface{}{ "login": r.URL.Query().Get("l"), "token": r.URL.Query().Get("t"), } diff --git a/static.go b/static.go index 6c5f316..4946083 100644 --- a/static.go +++ b/static.go @@ -5,8 +5,6 @@ import ( "html/template" "log" "net/http" - "net/url" - "strings" ) func securityHeaders(next http.Handler) http.Handler { @@ -14,16 +12,7 @@ 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") - - 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) - + 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") if !devMode { w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") } @@ -34,26 +23,7 @@ func securityHeaders(next http.Handler) http.Handler { //go:embed all:static var assets embed.FS -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 - } - +func displayTmpl(w http.ResponseWriter, page string, vars map[string]interface{}) { data, err := assets.ReadFile("static/" + page) if err != nil { log.Fatalf("Unable to find %q: %s", page, err.Error()) @@ -75,7 +45,7 @@ func displayTmpl(w http.ResponseWriter, page string, vars map[string]any) { tpl.ExecuteTemplate(w, "page", vars) } -func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]any) { +func displayTmplError(w http.ResponseWriter, statusCode int, page string, vars map[string]interface{}) { w.WriteHeader(statusCode) displayTmpl(w, page, vars) } @@ -88,5 +58,5 @@ func displayMsg(w http.ResponseWriter, msg string, statusCode int) { label = "message" } - displayTmpl(w, "message.html", map[string]any{label: msg}) + displayTmpl(w, "message.html", map[string]interface{}{label: msg}) } diff --git a/static/change.html b/static/change.html index 5b6fcaf..424b171 100644 --- a/static/change.html +++ b/static/change.html @@ -1,46 +1,49 @@ -{{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 2d6e1aa..e7c1c45 100644 --- a/static/footer.html +++ b/static/footer.html @@ -1,4 +1,5 @@ {{define "footer"}} +
diff --git a/static/header.html b/static/header.html index 6591d91..f86b55b 100644 --- a/static/header.html +++ b/static/header.html @@ -1,17 +1,18 @@ {{define "header"}} - - - - {{if .brand_name}}{{.brand_name}} - {{end}}Password management - - - - -
-
- {{if .brand_logo}}{{end}} - {{if .brand_name}}{{.brand_name}}{{else}}Password management{{end}} -
+ + + + + + + + + nemunai.re password change + + + +
+
{{end}} diff --git a/static/login.html b/static/login.html index 10ffa71..ec0678a 100644 --- a/static/login.html +++ b/static/login.html @@ -1,21 +1,18 @@ -{{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 2044c1c..bba8878 100644 --- a/static/lost.html +++ b/static/lost.html @@ -1,19 +1,16 @@ -{{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 081b18f..f377b6e 100644 --- a/static/message.html +++ b/static/message.html @@ -1,7 +1,5 @@ -{{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 deleted file mode 100644 index 680c197..0000000 --- a/static/profile.html +++ /dev/null @@ -1,76 +0,0 @@ -{{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 dee828f..382ab00 100644 --- a/static/reset.html +++ b/static/reset.html @@ -1,26 +1,23 @@ -{{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 deleted file mode 100644 index 2f24d06..0000000 --- a/static/style.css +++ /dev/null @@ -1,479 +0,0 @@ -/* ============================================================ - 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; -}