feat: Supabase password auth with @hoganlovells.com restriction + lime green branding
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
"mgit.msbls.de/m/patholo/internal/handlers"
|
||||
)
|
||||
|
||||
@@ -14,8 +15,16 @@ func main() {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
supabaseURL := os.Getenv("SUPABASE_URL")
|
||||
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
|
||||
if supabaseURL == "" || supabaseAnonKey == "" {
|
||||
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
|
||||
}
|
||||
|
||||
client := auth.NewClient(supabaseURL, supabaseAnonKey)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
handlers.Register(mux)
|
||||
handlers.Register(mux, client)
|
||||
|
||||
log.Printf("patholo server starting on :%s", port)
|
||||
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
||||
|
||||
@@ -5,4 +5,6 @@ services:
|
||||
- "8080"
|
||||
environment:
|
||||
- PORT=8080
|
||||
- SUPABASE_URL=${SUPABASE_URL}
|
||||
- SUPABASE_ANON_KEY=${SUPABASE_ANON_KEY}
|
||||
restart: unless-stopped
|
||||
|
||||
265
internal/auth/auth.go
Normal file
265
internal/auth/auth.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SessionCookieName = "patholo_session"
|
||||
RefreshCookieName = "patholo_refresh"
|
||||
CookieMaxAge = 30 * 24 * 60 * 60 // 30 days
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
URL string
|
||||
AnonKey string
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
func NewClient(supabaseURL, anonKey string) *Client {
|
||||
return &Client{
|
||||
URL: strings.TrimRight(supabaseURL, "/"),
|
||||
AnonKey: anonKey,
|
||||
HTTP: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
// SignIn authenticates a user with email and password.
|
||||
func (c *Client) SignIn(email, password string) (*TokenResponse, error) {
|
||||
return c.tokenRequest("password", map[string]string{
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
}
|
||||
|
||||
// RefreshSession exchanges a refresh token for a new access token.
|
||||
func (c *Client) RefreshSession(refreshToken string) (*TokenResponse, error) {
|
||||
return c.tokenRequest("refresh_token", map[string]string{
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) tokenRequest(grantType string, body map[string]string) (*TokenResponse, error) {
|
||||
jsonBody, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/auth/v1/token?grant_type=%s", c.URL, grantType)
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("apikey", c.AnonKey)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody))
|
||||
}
|
||||
|
||||
var result TokenResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, errors.New("empty access_token")
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SignUp registers a new user. Returns tokens if auto-confirm is enabled, nil otherwise.
|
||||
func (c *Client) SignUp(email, password string) (*TokenResponse, error) {
|
||||
jsonBody, err := json.Marshal(map[string]string{
|
||||
"email": email,
|
||||
"password": password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/auth/v1/signup", c.URL)
|
||||
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(jsonBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("apikey", c.AnonKey)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, parseErrorMessage(respBody))
|
||||
}
|
||||
|
||||
var result TokenResponse
|
||||
if err := json.Unmarshal(respBody, &result); err != nil || result.AccessToken == "" {
|
||||
return nil, nil // registered but no auto-login (email confirmation pending)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// SignOut invalidates the user's session on Supabase.
|
||||
func (c *Client) SignOut(accessToken string) {
|
||||
endpoint := fmt.Sprintf("%s/auth/v1/logout", c.URL)
|
||||
req, err := http.NewRequest("POST", endpoint, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("apikey", c.AnonKey)
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// DecodeJWTExpiry reads the exp claim from a JWT without signature verification.
|
||||
func DecodeJWTExpiry(token string) (time.Time, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return time.Time{}, errors.New("invalid token format")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
|
||||
var claims struct {
|
||||
Exp float64 `json:"exp"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse claims: %w", err)
|
||||
}
|
||||
if claims.Exp == 0 {
|
||||
return time.Time{}, errors.New("no exp claim")
|
||||
}
|
||||
return time.Unix(int64(claims.Exp), 0), nil
|
||||
}
|
||||
|
||||
// Middleware requires a valid session for protected routes.
|
||||
func (c *Client) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sessionCookie, err := r.Cookie(SessionCookieName)
|
||||
if err != nil || sessionCookie.Value == "" {
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
exp, err := DecodeJWTExpiry(sessionCookie.Value)
|
||||
if err != nil {
|
||||
ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(exp) {
|
||||
// Access token expired — try refresh
|
||||
refreshCookie, err := r.Cookie(RefreshCookieName)
|
||||
if err != nil || refreshCookie.Value == "" {
|
||||
ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := c.RefreshSession(refreshCookie.Value)
|
||||
if err != nil {
|
||||
log.Printf("token refresh failed: %v", err)
|
||||
ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
SetAuthCookies(w, r, tokens)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// SetAuthCookies writes session and refresh token cookies.
|
||||
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
|
||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||
for _, c := range []*http.Cookie{
|
||||
{Name: SessionCookieName, Value: tokens.AccessToken},
|
||||
{Name: RefreshCookieName, Value: tokens.RefreshToken},
|
||||
} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: c.Name,
|
||||
Value: c.Value,
|
||||
Path: "/",
|
||||
MaxAge: CookieMaxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: secure,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ClearAuthCookies removes session and refresh token cookies.
|
||||
func ClearAuthCookies(w http.ResponseWriter) {
|
||||
for _, name := range []string{SessionCookieName, RefreshCookieName} {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseErrorMessage(body []byte) string {
|
||||
var resp struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return string(body)
|
||||
}
|
||||
for _, msg := range []string{resp.ErrorDescription, resp.Message, resp.Msg, resp.Error} {
|
||||
if msg != "" {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
174
internal/handlers/auth.go
Normal file
174
internal/handlers/auth.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
)
|
||||
|
||||
type loginData struct {
|
||||
Mode string
|
||||
Error string
|
||||
Success string
|
||||
Email string
|
||||
}
|
||||
|
||||
func handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
handleLoginPage(w, r)
|
||||
case http.MethodPost:
|
||||
handleLoginSubmit(w, r)
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleLoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil && cookie.Value != "" {
|
||||
if exp, err := auth.DecodeJWTExpiry(cookie.Value); err == nil && time.Now().Before(exp) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data := loginData{
|
||||
Mode: r.URL.Query().Get("mode"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
if data.Mode == "" {
|
||||
data.Mode = "login"
|
||||
}
|
||||
renderPage(w, "login.html", data)
|
||||
}
|
||||
|
||||
func handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
|
||||
if email == "" || password == "" {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Error: "Bitte E-Mail und Passwort eingeben.",
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !isHoganLovellsEmail(email) {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Error: "Zugang nur für @hoganlovells.com E-Mail-Adressen.",
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := authClient.SignIn(email, password)
|
||||
if err != nil {
|
||||
log.Printf("sign in failed for %s: %v", email, err)
|
||||
errMsg := "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
if strings.Contains(err.Error(), "Invalid login credentials") {
|
||||
errMsg = "Ungültige E-Mail-Adresse oder Passwort."
|
||||
}
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Error: errMsg,
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
auth.SetAuthCookies(w, r, tokens)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.TrimSpace(r.FormValue("email"))
|
||||
password := r.FormValue("password")
|
||||
confirm := r.FormValue("confirm")
|
||||
|
||||
if email == "" || password == "" {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Bitte alle Felder ausfüllen.",
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if password != confirm {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Passwörter stimmen nicht überein.",
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(password) < 8 {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Passwort muss mindestens 8 Zeichen lang sein.",
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !isHoganLovellsEmail(email) {
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: "Registrierung nur für @hoganlovells.com E-Mail-Adressen.",
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tokens, err := authClient.SignUp(email, password)
|
||||
if err != nil {
|
||||
log.Printf("sign up failed for %s: %v", email, err)
|
||||
errMsg := "Registrierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
if strings.Contains(err.Error(), "already registered") || strings.Contains(err.Error(), "already been registered") {
|
||||
errMsg = "Ein Account mit dieser E-Mail existiert bereits."
|
||||
}
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "register",
|
||||
Error: errMsg,
|
||||
Email: email,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if tokens != nil {
|
||||
auth.SetAuthCookies(w, r, tokens)
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
renderPage(w, "login.html", loginData{
|
||||
Mode: "login",
|
||||
Success: "Account erstellt. Bitte melden Sie sich an.",
|
||||
Email: email,
|
||||
})
|
||||
}
|
||||
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(auth.SessionCookieName); err == nil {
|
||||
authClient.SignOut(cookie.Value)
|
||||
}
|
||||
auth.ClearAuthCookies(w)
|
||||
http.Redirect(w, r, "/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func isHoganLovellsEmail(email string) bool {
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
return len(parts) == 2 && strings.EqualFold(parts[1], "hoganlovells.com")
|
||||
}
|
||||
@@ -5,19 +5,55 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/auth"
|
||||
)
|
||||
|
||||
var templates *template.Template
|
||||
var (
|
||||
templates map[string]*template.Template
|
||||
authClient *auth.Client
|
||||
)
|
||||
|
||||
func Register(mux *http.ServeMux) {
|
||||
var err error
|
||||
templates, err = template.ParseGlob(filepath.Join("templates", "*.html"))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse templates: %v", err)
|
||||
func Register(mux *http.ServeMux, client *auth.Client) {
|
||||
authClient = client
|
||||
|
||||
// Parse each page template separately so "content" blocks don't collide
|
||||
templates = make(map[string]*template.Template)
|
||||
for _, page := range []string{"index.html", "login.html"} {
|
||||
t, err := template.ParseFiles(
|
||||
filepath.Join("templates", "base.html"),
|
||||
filepath.Join("templates", page),
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf("parse template %s: %v", page, err)
|
||||
}
|
||||
templates[page] = t
|
||||
}
|
||||
|
||||
// Public routes
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
mux.HandleFunc("/", handleIndex)
|
||||
mux.HandleFunc("/login", handleLogin)
|
||||
mux.HandleFunc("/register", handleRegister)
|
||||
mux.HandleFunc("/logout", handleLogout)
|
||||
|
||||
// Protected routes — everything else goes through auth middleware
|
||||
protected := http.NewServeMux()
|
||||
protected.HandleFunc("/", handleIndex)
|
||||
mux.Handle("/", client.Middleware(protected))
|
||||
}
|
||||
|
||||
func renderPage(w http.ResponseWriter, name string, data interface{}) {
|
||||
t, ok := templates[name]
|
||||
if !ok {
|
||||
log.Printf("template %s not found", name)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -25,9 +61,5 @@ func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := templates.ExecuteTemplate(w, "index.html", nil); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
}
|
||||
renderPage(w, "index.html", nil)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
--color-surface: #ffffff;
|
||||
--color-text: #1a1a2e;
|
||||
--color-text-muted: #64647a;
|
||||
--color-accent: #1b365d; /* HL navy */
|
||||
--color-accent-light: #2a5298;
|
||||
--color-accent: #65a30d; /* lime green */
|
||||
--color-accent-light: #84cc16;
|
||||
--color-border: #e5e5ed;
|
||||
--color-hero-bg: #1b365d;
|
||||
--color-hero-bg: #1a2e1a; /* dark forest */
|
||||
--color-hero-text: #ffffff;
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", monospace;
|
||||
@@ -85,6 +85,22 @@ main {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.nav-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.nav-logout {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-logout:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav-lang {
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -250,6 +266,141 @@ main {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ─── Login ─── */
|
||||
|
||||
.login-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 2.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.login-tab {
|
||||
flex: 1;
|
||||
padding: 0.6rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.login-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.login-tab.active {
|
||||
color: var(--color-accent);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.login-error {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-success {
|
||||
background: #f0fdf4;
|
||||
color: #166534;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.login-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 0.35rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.login-label:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.92rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.login-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px rgba(101, 163, 13, 0.15);
|
||||
}
|
||||
|
||||
.login-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
padding: 0.65rem 1rem;
|
||||
margin-top: 1.25rem;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-accent);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.login-button:hover {
|
||||
background: var(--color-accent-light);
|
||||
}
|
||||
|
||||
.login-hint {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<nav class="nav">
|
||||
<div class="logo">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-mark">p</span>
|
||||
<span class="logo-text">patholo</span>
|
||||
</div>
|
||||
<div class="nav-lang">
|
||||
<span class="lang-active">DE</span>
|
||||
<span class="lang-sep">/</span>
|
||||
<span class="lang-inactive">EN</span>
|
||||
</a>
|
||||
<div class="nav-right">
|
||||
<a href="/logout" class="nav-logout">Abmelden</a>
|
||||
<div class="nav-lang">
|
||||
<span class="lang-active">DE</span>
|
||||
<span class="lang-sep">/</span>
|
||||
<span class="lang-inactive">EN</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
71
templates/login.html
Normal file
71
templates/login.html
Normal file
@@ -0,0 +1,71 @@
|
||||
{{define "login.html"}}
|
||||
{{template "base" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<nav class="nav">
|
||||
<a href="/" class="logo">
|
||||
<span class="logo-mark">p</span>
|
||||
<span class="logo-text">patholo</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="login-main">
|
||||
<div class="login-card">
|
||||
<div class="login-tabs">
|
||||
<button class="login-tab{{if ne .Mode "register"}} active{{end}}" data-tab="login">Anmelden</button>
|
||||
<button class="login-tab{{if eq .Mode "register"}} active{{end}}" data-tab="register">Registrieren</button>
|
||||
</div>
|
||||
|
||||
{{if .Error}}
|
||||
<div class="login-error">{{.Error}}</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Success}}
|
||||
<div class="login-success">{{.Success}}</div>
|
||||
{{end}}
|
||||
|
||||
<form method="POST" action="/login" class="login-form" id="login-form"{{if eq .Mode "register"}} style="display:none"{{end}}>
|
||||
<label for="login-email" class="login-label">E-Mail</label>
|
||||
<input type="email" id="login-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required autofocus class="login-input">
|
||||
<label for="login-password" class="login-label">Passwort</label>
|
||||
<input type="password" id="login-password" name="password" placeholder="Passwort" required class="login-input">
|
||||
<button type="submit" class="login-button">Anmelden</button>
|
||||
</form>
|
||||
|
||||
<form method="POST" action="/register" class="login-form" id="register-form"{{if ne .Mode "register"}} style="display:none"{{end}}>
|
||||
<label for="reg-email" class="login-label">E-Mail</label>
|
||||
<input type="email" id="reg-email" name="email" value="{{.Email}}" placeholder="name@hoganlovells.com" required class="login-input">
|
||||
<label for="reg-password" class="login-label">Passwort</label>
|
||||
<input type="password" id="reg-password" name="password" placeholder="Mind. 8 Zeichen" required minlength="8" class="login-input">
|
||||
<label for="reg-confirm" class="login-label">Passwort bestätigen</label>
|
||||
<input type="password" id="reg-confirm" name="confirm" placeholder="Passwort wiederholen" required minlength="8" class="login-input">
|
||||
<button type="submit" class="login-button">Registrieren</button>
|
||||
</form>
|
||||
|
||||
<p class="login-hint">Nur für @hoganlovells.com Adressen.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© 2026 patholo — Internal use only. Hogan Lovells Patent Practice.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.login-tab').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.login-tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
document.getElementById('login-form').style.display = btn.dataset.tab === 'login' ? '' : 'none';
|
||||
document.getElementById('register-form').style.display = btn.dataset.tab === 'register' ? '' : 'none';
|
||||
document.querySelectorAll('.login-error, .login-success').forEach(function(el) { el.style.display = 'none'; });
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user