Implement OIDC single sign-on #27
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -31,4 +31,7 @@ tls/
 | 
			
		||||
test.db.old
 | 
			
		||||
.gitignore
 | 
			
		||||
.nvim/session
 | 
			
		||||
*templ.txt
 | 
			
		||||
*templ.txt
 | 
			
		||||
 | 
			
		||||
# env files
 | 
			
		||||
.env*
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,12 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
 | 
			
		||||
@ -150,9 +153,6 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
	ts := newTestServer(t, app.routes())
 | 
			
		||||
	defer ts.Close()
 | 
			
		||||
 | 
			
		||||
	_, _, body := ts.get(t, fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1)))
 | 
			
		||||
	validCSRFToken := extractCSRFToken(t, body)
 | 
			
		||||
 | 
			
		||||
	const (
 | 
			
		||||
		validAuthorName  = "John Test"
 | 
			
		||||
		validAuthorEmail = "test@example.com"
 | 
			
		||||
@ -166,8 +166,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
		authorEmail string
 | 
			
		||||
		authorSite  string
 | 
			
		||||
		content     string
 | 
			
		||||
		csrfToken   string
 | 
			
		||||
		wantCode    int
 | 
			
		||||
		wantBody    string
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name:        "Valid input",
 | 
			
		||||
@ -175,8 +175,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
			authorEmail: validAuthorEmail,
 | 
			
		||||
			authorSite:  validAuthorSite,
 | 
			
		||||
			content:     validContent,
 | 
			
		||||
			csrfToken:   validCSRFToken,
 | 
			
		||||
			wantCode:    http.StatusSeeOther,
 | 
			
		||||
			wantCode:    http.StatusOK,
 | 
			
		||||
			wantBody:    "Comment successfully posted",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:        "Blank name",
 | 
			
		||||
@ -184,8 +184,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
			authorEmail: validAuthorEmail,
 | 
			
		||||
			authorSite:  validAuthorSite,
 | 
			
		||||
			content:     validContent,
 | 
			
		||||
			csrfToken:   validCSRFToken,
 | 
			
		||||
			wantCode:    http.StatusUnprocessableEntity,
 | 
			
		||||
			wantCode:    http.StatusOK,
 | 
			
		||||
			wantBody:    "An error occurred",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:        "Blank email",
 | 
			
		||||
@ -193,8 +193,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
			authorEmail: "",
 | 
			
		||||
			authorSite:  validAuthorSite,
 | 
			
		||||
			content:     validContent,
 | 
			
		||||
			csrfToken:   validCSRFToken,
 | 
			
		||||
			wantCode:    http.StatusSeeOther,
 | 
			
		||||
			wantCode:    http.StatusOK,
 | 
			
		||||
			wantBody:    "Comment successfully posted",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:        "Blank site",
 | 
			
		||||
@ -202,8 +202,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
			authorEmail: validAuthorEmail,
 | 
			
		||||
			authorSite:  "",
 | 
			
		||||
			content:     validContent,
 | 
			
		||||
			csrfToken:   validCSRFToken,
 | 
			
		||||
			wantCode:    http.StatusSeeOther,
 | 
			
		||||
			wantCode:    http.StatusOK,
 | 
			
		||||
			wantBody:    "Comment successfully posted",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:        "Blank content",
 | 
			
		||||
@ -211,21 +211,39 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) {
 | 
			
		||||
			authorEmail: validAuthorEmail,
 | 
			
		||||
			authorSite:  validAuthorSite,
 | 
			
		||||
			content:     "",
 | 
			
		||||
			csrfToken:   validCSRFToken,
 | 
			
		||||
			wantCode:    http.StatusUnprocessableEntity,
 | 
			
		||||
			wantCode:    http.StatusOK,
 | 
			
		||||
			wantBody:    "An error occurred",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
			form := url.Values{}
 | 
			
		||||
			form.Add("authorname", tt.authorName)
 | 
			
		||||
			form.Add("authoremail", tt.authorEmail)
 | 
			
		||||
			form.Add("authorsite", tt.authorSite)
 | 
			
		||||
			form.Add("content", tt.content)
 | 
			
		||||
			form.Add("csrf_token", tt.csrfToken)
 | 
			
		||||
			code, _, body := ts.postForm(t, fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1)), form)
 | 
			
		||||
			assert.Equal(t, code, tt.wantCode)
 | 
			
		||||
			assert.Equal(t, body, body)
 | 
			
		||||
			r, err := http.NewRequest("POST", ts.URL, strings.NewReader(form.Encode()))
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			r.URL.Path = fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1))
 | 
			
		||||
			r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 | 
			
		||||
			r.Header.Set("Origin", "http://example.com")
 | 
			
		||||
 | 
			
		||||
			resp, err := ts.Client().Do(r)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			defer resp.Body.Close()
 | 
			
		||||
			body, err := io.ReadAll(resp.Body)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			body = bytes.TrimSpace(body)
 | 
			
		||||
			assert.Equal(t, resp.StatusCode, tt.wantCode)
 | 
			
		||||
			assert.StringContains(t, string(body), tt.wantBody)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -9,15 +9,22 @@ import (
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/models"
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/validator"
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/ui/views"
 | 
			
		||||
	"github.com/coreos/go-oidc/v3/oidc"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if !app.config.localAuthEnabled {
 | 
			
		||||
		http.Redirect(w, r, "/users/login/oidc", http.StatusFound)
 | 
			
		||||
	}
 | 
			
		||||
	form := forms.UserRegistrationForm{}
 | 
			
		||||
	data := app.newCommonData(r)
 | 
			
		||||
	views.UserRegistration("User Registration", data, form).Render(r.Context(), w)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (app *application) getUserLogin(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if !app.config.localAuthEnabled {
 | 
			
		||||
		http.Redirect(w, r, "/users/login/oidc", http.StatusFound)
 | 
			
		||||
	}
 | 
			
		||||
	views.UserLogin("Login", app.newCommonData(r), forms.UserLoginForm{}).Render(r.Context(), w)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -92,6 +99,113 @@ func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	http.Redirect(w, r, "/", http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (app *application) userLoginOIDC(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	if !app.config.oauthEnabled {
 | 
			
		||||
		http.Redirect(w, r, "/users/login", http.StatusFound)
 | 
			
		||||
	}
 | 
			
		||||
	state, err := randString(16)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	nonce, err := randString(16)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	setCallbackCookie(w, r, "state", state)
 | 
			
		||||
	setCallbackCookie(w, r, "nonce", nonce)
 | 
			
		||||
 | 
			
		||||
	http.Redirect(w, r, app.config.oauth.config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (app *application) userLoginOIDCCallback(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	state, err := r.Cookie("state")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.clientError(w, http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if r.URL.Query().Get("state") != state.Value {
 | 
			
		||||
		app.clientError(w, http.StatusBadRequest)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	oauth2Token, err := app.config.oauth.config.Exchange(r.Context(), r.URL.Query().Get("code"))
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.logger.Error("Failed to exchange token")
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
 | 
			
		||||
	if !ok {
 | 
			
		||||
		app.serverError(w, r, errors.New("No id_token field in oauth2 token"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	idToken, err := app.config.oauth.verifier.Verify(r.Context(), rawIDToken)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.logger.Error("Failed to verify ID token")
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	nonce, err := r.Cookie("nonce")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.logger.Error("nonce not found")
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if idToken.Nonce != nonce.Value {
 | 
			
		||||
		app.serverError(w, r, errors.New("nonce did not match"))
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	oauth2Token.AccessToken = "*REDACTED*"
 | 
			
		||||
 | 
			
		||||
	var t models.UserIdToken
 | 
			
		||||
	if err := idToken.Claims(&t); err != nil {
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = app.sessionManager.RenewToken(r.Context())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// search for user by subject
 | 
			
		||||
	id, err := app.users.GetBySubject(t.Subject)
 | 
			
		||||
	if err != nil && errors.Is(err, models.ErrNoRecord) {
 | 
			
		||||
		// if no user is found, check if they have signed up by email already
 | 
			
		||||
		id, err = app.users.GetByEmail(t.Email)
 | 
			
		||||
		if err == nil {
 | 
			
		||||
			// if user is found by email, update subject to match them in the first step next time
 | 
			
		||||
			err2 := app.users.UpdateSubject(id, t.Subject)
 | 
			
		||||
			if err2 != nil {
 | 
			
		||||
				app.serverError(w, r, err2)
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && errors.Is(err, models.ErrNoRecord) {
 | 
			
		||||
		// if no user is found by subject or email, create a new user
 | 
			
		||||
		id, err = app.users.InsertWithoutPassword(app.createShortId(), t.Username, t.Email, t.Subject, DefaultUserSettings())
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			app.serverError(w, r, err)
 | 
			
		||||
		}
 | 
			
		||||
	} else if err != nil {
 | 
			
		||||
		app.serverError(w, r, err)
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	app.sessionManager.Put(r.Context(), "authenticatedUserId", id)
 | 
			
		||||
	http.Redirect(w, r, "/", http.StatusSeeOther)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
	err := app.sessionManager.RenewToken(r.Context())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
 | 
			
		||||
@ -1,11 +1,17 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"crypto/rsa"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/assert"
 | 
			
		||||
	"github.com/coreos/go-oidc/v3/oidc"
 | 
			
		||||
	"github.com/coreos/go-oidc/v3/oidc/oidctest"
 | 
			
		||||
	"golang.org/x/oauth2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestUserSignup(t *testing.T) {
 | 
			
		||||
@ -119,3 +125,162 @@ func TestUserSignup(t *testing.T) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type OAuth2Mock struct {
 | 
			
		||||
	Srv     *testServer
 | 
			
		||||
	Priv    *rsa.PrivateKey
 | 
			
		||||
	Subject string
 | 
			
		||||
	Email   string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (o *OAuth2Mock) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
 | 
			
		||||
	return ""
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (o *OAuth2Mock) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
 | 
			
		||||
	tkn := oauth2.Token{
 | 
			
		||||
		AccessToken: "AccessToken",
 | 
			
		||||
		Expiry:      time.Now().Add(1 * time.Hour),
 | 
			
		||||
	}
 | 
			
		||||
	m := make(map[string]any)
 | 
			
		||||
	var rawClaims = `{
 | 
			
		||||
		"iss": "` + o.Srv.URL + `",
 | 
			
		||||
		"aud": "my-client-id",
 | 
			
		||||
		"sub": "` + o.Subject + `",
 | 
			
		||||
		"email": "` + o.Email + `",
 | 
			
		||||
		"email_verified": true,
 | 
			
		||||
		"nonce": "nonce"
 | 
			
		||||
		}`
 | 
			
		||||
 | 
			
		||||
	m["id_token"] = oidctest.SignIDToken(o.Priv, "test-key", oidc.RS256, rawClaims)
 | 
			
		||||
	return tkn.WithExtra(m), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (o *OAuth2Mock) Client(ctx context.Context, t *oauth2.Token) *http.Client {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestUserOIDCCallback(t *testing.T) {
 | 
			
		||||
	app := newTestApplication(t)
 | 
			
		||||
	ts := newTestServer(t, app.routes())
 | 
			
		||||
 | 
			
		||||
	priv := newTestKey(t)
 | 
			
		||||
	srv := newTestOIDCServer(t, priv)
 | 
			
		||||
 | 
			
		||||
	defer srv.Close()
 | 
			
		||||
	defer ts.Close()
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
 | 
			
		||||
	p, err := oidc.NewProvider(ctx, srv.URL)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	cfg := &oidc.Config{
 | 
			
		||||
		ClientID:        "my-client-id",
 | 
			
		||||
		SkipExpiryCheck: true,
 | 
			
		||||
	}
 | 
			
		||||
	v := p.VerifierContext(ctx, cfg)
 | 
			
		||||
	oMock := &OAuth2Mock{
 | 
			
		||||
		Srv:     srv,
 | 
			
		||||
		Priv:    priv,
 | 
			
		||||
		Subject: "foo",
 | 
			
		||||
	}
 | 
			
		||||
	app.config.oauth = applicationOauthConfig{
 | 
			
		||||
		ctx:        context.Background(),
 | 
			
		||||
		oidcConfig: cfg,
 | 
			
		||||
		config:     oMock,
 | 
			
		||||
		provider:   p,
 | 
			
		||||
		verifier:   v,
 | 
			
		||||
	}
 | 
			
		||||
	app.config.oauthEnabled = true
 | 
			
		||||
 | 
			
		||||
	const (
 | 
			
		||||
		validSubject   = "goodSubject"
 | 
			
		||||
		unknownSubject = "foo"
 | 
			
		||||
		validUserId    = 1
 | 
			
		||||
		validEmail     = "test@example.com"
 | 
			
		||||
		validState     = "goodState"
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name     string
 | 
			
		||||
		subject  string
 | 
			
		||||
		email    string
 | 
			
		||||
		state    string
 | 
			
		||||
		wantCode int
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name:     "By Subject",
 | 
			
		||||
			subject:  validSubject,
 | 
			
		||||
			email:    "",
 | 
			
		||||
			state:    validState,
 | 
			
		||||
			wantCode: http.StatusSeeOther,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "By Email",
 | 
			
		||||
			subject:  unknownSubject,
 | 
			
		||||
			email:    validEmail,
 | 
			
		||||
			state:    validState,
 | 
			
		||||
			wantCode: http.StatusSeeOther,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "No User",
 | 
			
		||||
			subject:  unknownSubject,
 | 
			
		||||
			email:    "",
 | 
			
		||||
			state:    validState,
 | 
			
		||||
			wantCode: http.StatusSeeOther,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "Invalid State",
 | 
			
		||||
			subject:  unknownSubject,
 | 
			
		||||
			email:    validEmail,
 | 
			
		||||
			state:    "",
 | 
			
		||||
			wantCode: http.StatusInternalServerError,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "Unknown Subject & Email",
 | 
			
		||||
			subject:  unknownSubject,
 | 
			
		||||
			email:    "",
 | 
			
		||||
			state:    validState,
 | 
			
		||||
			wantCode: http.StatusInternalServerError,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(*testing.T) {
 | 
			
		||||
			oMock.Subject = tt.subject
 | 
			
		||||
			oMock.Email = tt.email
 | 
			
		||||
			r, err := http.NewRequest("GET", ts.URL, nil)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			r.URL.Path = "/users/login/oidc/callback"
 | 
			
		||||
			q := r.URL.Query()
 | 
			
		||||
			q.Add("state", tt.state)
 | 
			
		||||
			r.URL.RawQuery = q.Encode()
 | 
			
		||||
 | 
			
		||||
			c := &http.Cookie{
 | 
			
		||||
				Name:     "state",
 | 
			
		||||
				Value:    validState,
 | 
			
		||||
				MaxAge:   int(time.Hour.Seconds()),
 | 
			
		||||
				Secure:   r.TLS != nil,
 | 
			
		||||
				HttpOnly: true,
 | 
			
		||||
			}
 | 
			
		||||
			d := &http.Cookie{
 | 
			
		||||
				Name:     "nonce",
 | 
			
		||||
				Value:    "nonce",
 | 
			
		||||
				MaxAge:   int(time.Hour.Seconds()),
 | 
			
		||||
				Secure:   r.TLS != nil,
 | 
			
		||||
				HttpOnly: true,
 | 
			
		||||
			}
 | 
			
		||||
			r.AddCookie(c)
 | 
			
		||||
			r.AddCookie(d)
 | 
			
		||||
			resp, err := ts.Client().Do(r)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			assert.Equal(t, resp.StatusCode, tt.wantCode)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,11 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"crypto/rand"
 | 
			
		||||
	"encoding/base64"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"math"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
@ -104,13 +107,15 @@ func (app *application) getCurrentUser(r *http.Request) *models.User {
 | 
			
		||||
 | 
			
		||||
func (app *application) newCommonData(r *http.Request) views.CommonData {
 | 
			
		||||
	return views.CommonData{
 | 
			
		||||
		CurrentYear:     time.Now().Year(),
 | 
			
		||||
		Flash:           app.sessionManager.PopString(r.Context(), "flash"),
 | 
			
		||||
		IsAuthenticated: app.isAuthenticated(r),
 | 
			
		||||
		CSRFToken:       nosurf.Token(r),
 | 
			
		||||
		CurrentUser:     app.getCurrentUser(r),
 | 
			
		||||
		IsHtmx:          r.Header.Get("Hx-Request") == "true",
 | 
			
		||||
		RootUrl:         app.rootUrl,
 | 
			
		||||
		CurrentYear:      time.Now().Year(),
 | 
			
		||||
		Flash:            app.sessionManager.PopString(r.Context(), "flash"),
 | 
			
		||||
		IsAuthenticated:  app.isAuthenticated(r),
 | 
			
		||||
		CSRFToken:        nosurf.Token(r),
 | 
			
		||||
		CurrentUser:      app.getCurrentUser(r),
 | 
			
		||||
		IsHtmx:           r.Header.Get("Hx-Request") == "true",
 | 
			
		||||
		RootUrl:          app.config.rootUrl,
 | 
			
		||||
		LocalAuthEnabled: app.config.localAuthEnabled,
 | 
			
		||||
		OIDCEnabled:      app.config.oauthEnabled,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -140,3 +145,22 @@ func matchOrigin(origin string, u *url.URL) bool {
 | 
			
		||||
	}
 | 
			
		||||
	return true
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func randString(nByte int) (string, error) {
 | 
			
		||||
	b := make([]byte, nByte)
 | 
			
		||||
	if _, err := io.ReadFull(rand.Reader, b); err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	return base64.RawURLEncoding.EncodeToString(b), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) {
 | 
			
		||||
	c := &http.Cookie{
 | 
			
		||||
		Name:     name,
 | 
			
		||||
		Value:    value,
 | 
			
		||||
		MaxAge:   int(time.Hour.Seconds()),
 | 
			
		||||
		Secure:   r.TLS != nil,
 | 
			
		||||
		HttpOnly: true,
 | 
			
		||||
	}
 | 
			
		||||
	http.SetCookie(w, c)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										109
									
								
								cmd/web/main.go
									
									
									
									
									
								
							
							
						
						
									
										109
									
								
								cmd/web/main.go
									
									
									
									
									
								
							@ -1,23 +1,47 @@
 | 
			
		||||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"crypto/tls"
 | 
			
		||||
	"database/sql"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"flag"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
	"unicode"
 | 
			
		||||
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/auth"
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/models"
 | 
			
		||||
	"github.com/alexedwards/scs/sqlite3store"
 | 
			
		||||
	"github.com/alexedwards/scs/v2"
 | 
			
		||||
	"github.com/coreos/go-oidc/v3/oidc"
 | 
			
		||||
	"github.com/gorilla/schema"
 | 
			
		||||
	"github.com/joho/godotenv"
 | 
			
		||||
	_ "github.com/mattn/go-sqlite3"
 | 
			
		||||
	"golang.org/x/oauth2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type applicationOauthConfig struct {
 | 
			
		||||
	ctx        context.Context
 | 
			
		||||
	oidcConfig *oidc.Config
 | 
			
		||||
	config     auth.OAuth2ConfigInterface
 | 
			
		||||
	provider   *oidc.Provider
 | 
			
		||||
	verifier   *oidc.IDTokenVerifier
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type applicationConfig struct {
 | 
			
		||||
	oauthEnabled     bool
 | 
			
		||||
	localAuthEnabled bool
 | 
			
		||||
	oauth            applicationOauthConfig
 | 
			
		||||
	rootUrl          string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type application struct {
 | 
			
		||||
	sequence          uint16
 | 
			
		||||
	logger            *slog.Logger
 | 
			
		||||
@ -26,19 +50,29 @@ type application struct {
 | 
			
		||||
	guestbookComments models.GuestbookCommentModelInterface
 | 
			
		||||
	sessionManager    *scs.SessionManager
 | 
			
		||||
	formDecoder       *schema.Decoder
 | 
			
		||||
	config            applicationConfig
 | 
			
		||||
	debug             bool
 | 
			
		||||
	timezones         []string
 | 
			
		||||
	rootUrl           string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func main() {
 | 
			
		||||
	addr := flag.String("addr", ":3000", "HTTP network address")
 | 
			
		||||
	dsn := flag.String("dsn", "guestbook.db", "data source name")
 | 
			
		||||
	debug := flag.Bool("debug", false, "enable debug mode")
 | 
			
		||||
	root := flag.String("root", "localhost:3000", "root URL of application")
 | 
			
		||||
	env := flag.String("env", ".env", ".env file path")
 | 
			
		||||
	flag.Parse()
 | 
			
		||||
 | 
			
		||||
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
 | 
			
		||||
	err := godotenv.Load(*env)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		logger.Error(err.Error())
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
	cfg, err := setupConfig(*addr)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		logger.Error(err.Error())
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	db, err := openDB(*dsn)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -62,9 +96,9 @@ func main() {
 | 
			
		||||
		users:             &models.UserModel{DB: db, Settings: make(map[string]models.Setting)},
 | 
			
		||||
		guestbookComments: &models.GuestbookCommentModel{DB: db},
 | 
			
		||||
		formDecoder:       formDecoder,
 | 
			
		||||
		config:            cfg,
 | 
			
		||||
		debug:             *debug,
 | 
			
		||||
		timezones:         getAvailableTimezones(),
 | 
			
		||||
		rootUrl:           *root,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = app.users.InitializeSettingsMap()
 | 
			
		||||
@ -114,6 +148,75 @@ func openDB(dsn string) (*sql.DB, error) {
 | 
			
		||||
	return db, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func setupConfig(addr string) (applicationConfig, error) {
 | 
			
		||||
	var c applicationConfig
 | 
			
		||||
 | 
			
		||||
	var (
 | 
			
		||||
		rootUrl           = os.Getenv("ROOT_URL")
 | 
			
		||||
		oidcEnabled       = os.Getenv("ENABLE_OIDC")
 | 
			
		||||
		localLoginEnabled = os.Getenv("ENABLE_LOCAL_LOGIN")
 | 
			
		||||
		oauth2Provider    = os.Getenv("OAUTH2_PROVIDER")
 | 
			
		||||
		clientID          = os.Getenv("OAUTH2_CLIENT_ID")
 | 
			
		||||
		clientSecret      = os.Getenv("OAUTH2_CLIENT_SECRET")
 | 
			
		||||
	)
 | 
			
		||||
	if rootUrl != "" {
 | 
			
		||||
		c.rootUrl = rootUrl
 | 
			
		||||
	} else {
 | 
			
		||||
		u, err := url.Parse(fmt.Sprintf("https://localhost%s", addr))
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return c, err
 | 
			
		||||
		}
 | 
			
		||||
		c.rootUrl = u.String()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	oauthEnabled, err := strconv.ParseBool(oidcEnabled)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.oauthEnabled = false
 | 
			
		||||
	}
 | 
			
		||||
	c.oauthEnabled = oauthEnabled
 | 
			
		||||
 | 
			
		||||
	localAuthEnabled, err := strconv.ParseBool(localLoginEnabled)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		c.localAuthEnabled = true
 | 
			
		||||
	}
 | 
			
		||||
	c.localAuthEnabled = localAuthEnabled
 | 
			
		||||
 | 
			
		||||
	if !c.oauthEnabled && !c.localAuthEnabled {
 | 
			
		||||
		return c, errors.New("Either ENABLE_OIDC or ENABLE_LOCAL_LOGIN must be set to true")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// if OIDC is disabled, no more configuration needs to be read
 | 
			
		||||
	if !oauthEnabled {
 | 
			
		||||
		return c, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var o applicationOauthConfig
 | 
			
		||||
	if oauth2Provider == "" || clientID == "" || clientSecret == "" {
 | 
			
		||||
		return c, errors.New("OAUTH2_PROVIDER, OAUTH2_CLIENT_ID, and OAUTH2_CLIENT_SECRET must be specified as environment variables.")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	o.ctx = context.Background()
 | 
			
		||||
	provider, err := oidc.NewProvider(o.ctx, oauth2Provider)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return c, err
 | 
			
		||||
	}
 | 
			
		||||
	o.provider = provider
 | 
			
		||||
	o.oidcConfig = &oidc.Config{
 | 
			
		||||
		ClientID: clientID,
 | 
			
		||||
	}
 | 
			
		||||
	o.verifier = provider.Verifier(o.oidcConfig)
 | 
			
		||||
	o.config = &oauth2.Config{
 | 
			
		||||
		ClientID:     clientID,
 | 
			
		||||
		ClientSecret: clientSecret,
 | 
			
		||||
		Endpoint:     provider.Endpoint(),
 | 
			
		||||
		RedirectURL:  fmt.Sprintf("%s/users/login/oidc/callback", c.rootUrl),
 | 
			
		||||
		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	c.oauth = o
 | 
			
		||||
	return c, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getAvailableTimezones() []string {
 | 
			
		||||
	var zones []string
 | 
			
		||||
	var zoneDirs = []string{
 | 
			
		||||
 | 
			
		||||
@ -27,6 +27,8 @@ func (app *application) routes() http.Handler {
 | 
			
		||||
	mux.Handle("POST /users/register", dynamic.ThenFunc(app.postUserRegister))
 | 
			
		||||
	mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin))
 | 
			
		||||
	mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin))
 | 
			
		||||
	mux.Handle("/users/login/oidc", dynamic.ThenFunc(app.userLoginOIDC))
 | 
			
		||||
	mux.Handle("/users/login/oidc/callback", dynamic.ThenFunc(app.userLoginOIDCCallback))
 | 
			
		||||
	mux.Handle("GET /help", dynamic.ThenFunc(app.notImplemented))
 | 
			
		||||
 | 
			
		||||
	protected := dynamic.Append(app.requireAuthentication)
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,8 @@ package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bytes"
 | 
			
		||||
	"crypto/rand"
 | 
			
		||||
	"crypto/rsa"
 | 
			
		||||
	"html"
 | 
			
		||||
	"io"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
@ -15,6 +17,8 @@ import (
 | 
			
		||||
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/models/mocks"
 | 
			
		||||
	"github.com/alexedwards/scs/v2"
 | 
			
		||||
	"github.com/coreos/go-oidc/v3/oidc"
 | 
			
		||||
	"github.com/coreos/go-oidc/v3/oidc/oidctest"
 | 
			
		||||
	"github.com/gorilla/schema"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -34,9 +38,35 @@ func newTestApplication(t *testing.T) *application {
 | 
			
		||||
		guestbookComments: &mocks.GuestbookCommentModel{},
 | 
			
		||||
		formDecoder:       formDecoder,
 | 
			
		||||
		timezones:         getAvailableTimezones(),
 | 
			
		||||
		config: applicationConfig{
 | 
			
		||||
			localAuthEnabled: true,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newTestKey(t *testing.T) *rsa.PrivateKey {
 | 
			
		||||
	priv, err := rsa.GenerateKey(rand.Reader, 2048)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	return priv
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func newTestOIDCServer(t *testing.T, priv *rsa.PrivateKey) *testServer {
 | 
			
		||||
	s := &oidctest.Server{
 | 
			
		||||
		PublicKeys: []oidctest.PublicKey{
 | 
			
		||||
			{
 | 
			
		||||
				PublicKey: priv.Public(),
 | 
			
		||||
				KeyID:     "test-key",
 | 
			
		||||
				Algorithm: oidc.ES256,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	ts := httptest.NewServer(s)
 | 
			
		||||
	s.SetIssuer(ts.URL)
 | 
			
		||||
	return &testServer{ts}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type testServer struct {
 | 
			
		||||
	*httptest.Server
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								go.mod
									
									
									
									
									
								
							@ -6,9 +6,14 @@ require (
 | 
			
		||||
	github.com/a-h/templ v0.3.833
 | 
			
		||||
	github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c
 | 
			
		||||
	github.com/alexedwards/scs/v2 v2.8.0
 | 
			
		||||
	github.com/coreos/go-oidc/v3 v3.14.1
 | 
			
		||||
	github.com/gorilla/schema v1.4.1
 | 
			
		||||
	github.com/joho/godotenv v1.5.1
 | 
			
		||||
	github.com/justinas/alice v1.2.0
 | 
			
		||||
	github.com/justinas/nosurf v1.1.1
 | 
			
		||||
	github.com/mattn/go-sqlite3 v1.14.24
 | 
			
		||||
	golang.org/x/crypto v0.36.0
 | 
			
		||||
	golang.org/x/oauth2 v0.30.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require github.com/go-jose/go-jose/v4 v4.0.5 // indirect
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										21
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								go.sum
									
									
									
									
									
								
							@ -1,24 +1,35 @@
 | 
			
		||||
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
 | 
			
		||||
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
 | 
			
		||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0=
 | 
			
		||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
 | 
			
		||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c h1:0gBCIsmH3+aaWK55APhhY7/Z+uv5IdbMqekI97V9shU=
 | 
			
		||||
github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss=
 | 
			
		||||
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=
 | 
			
		||||
github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
 | 
			
		||||
github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk=
 | 
			
		||||
github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 | 
			
		||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 | 
			
		||||
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
 | 
			
		||||
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
 | 
			
		||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 | 
			
		||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 | 
			
		||||
github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E=
 | 
			
		||||
github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM=
 | 
			
		||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 | 
			
		||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 | 
			
		||||
github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
 | 
			
		||||
github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
 | 
			
		||||
github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk=
 | 
			
		||||
github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ=
 | 
			
		||||
github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
 | 
			
		||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
 | 
			
		||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
 | 
			
		||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
 | 
			
		||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
 | 
			
		||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 | 
			
		||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 | 
			
		||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 | 
			
		||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 | 
			
		||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
 | 
			
		||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
 | 
			
		||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 | 
			
		||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										14
									
								
								internal/auth/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								internal/auth/auth.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
			
		||||
package auth
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"net/http"
 | 
			
		||||
 | 
			
		||||
	"golang.org/x/oauth2"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type OAuth2ConfigInterface interface {
 | 
			
		||||
	AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
 | 
			
		||||
	Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error)
 | 
			
		||||
	Client(ctx context.Context, t *oauth2.Token) *http.Client
 | 
			
		||||
}
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
package mocks
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"git.32bit.cafe/32bitcafe/guestbook/internal/models"
 | 
			
		||||
@ -38,6 +39,15 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, password string, settings models.UserSettings) (int64, error) {
 | 
			
		||||
	switch email {
 | 
			
		||||
	case "dupe@example.com":
 | 
			
		||||
		return -1, models.ErrDuplicateEmail
 | 
			
		||||
	default:
 | 
			
		||||
		return 2, nil
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) Get(shortId uint64) (models.User, error) {
 | 
			
		||||
	switch shortId {
 | 
			
		||||
	case 1:
 | 
			
		||||
@ -87,3 +97,26 @@ func (m *UserModel) UpdateUserSettings(userId int64, settings models.UserSetting
 | 
			
		||||
func (m *UserModel) UpdateSetting(userId int64, setting models.Setting, value string) error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) GetBySubject(subject string) (int64, error) {
 | 
			
		||||
	if subject == "goodSubject" {
 | 
			
		||||
		return 1, nil
 | 
			
		||||
	} else if subject == "foo" {
 | 
			
		||||
		return -1, models.ErrNoRecord
 | 
			
		||||
	}
 | 
			
		||||
	return -1, errors.New("Unexpected Error")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) GetByEmail(email string) (int64, error) {
 | 
			
		||||
	if email == "test@example.com" {
 | 
			
		||||
		return 1, nil
 | 
			
		||||
	}
 | 
			
		||||
	return -1, models.ErrNoRecord
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) UpdateSubject(userId int64, subject string) error {
 | 
			
		||||
	if userId == 1 {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
	return errors.New("invalid")
 | 
			
		||||
}
 | 
			
		||||
@ -35,16 +35,28 @@ type UserModel struct {
 | 
			
		||||
	Settings map[string]Setting
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserIdToken struct {
 | 
			
		||||
	Subject       string   `json:"sub"`
 | 
			
		||||
	Email         string   `json:"email"`
 | 
			
		||||
	EmailVerified bool     `json:"email_verified"`
 | 
			
		||||
	Username      string   `json:"preferred_username"`
 | 
			
		||||
	Groups        []string `json:"groups"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type UserModelInterface interface {
 | 
			
		||||
	InitializeSettingsMap() error
 | 
			
		||||
	Insert(shortId uint64, username string, email string, password string, settings UserSettings) error
 | 
			
		||||
	InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error)
 | 
			
		||||
	Get(shortId uint64) (User, error)
 | 
			
		||||
	GetById(id int64) (User, error)
 | 
			
		||||
	GetByEmail(email string) (int64, error)
 | 
			
		||||
	GetBySubject(subject string) (int64, error)
 | 
			
		||||
	GetAll() ([]User, error)
 | 
			
		||||
	Authenticate(email, password string) (int64, error)
 | 
			
		||||
	Exists(id int64) (bool, error)
 | 
			
		||||
	UpdateUserSettings(userId int64, settings UserSettings) error
 | 
			
		||||
	UpdateSetting(userId int64, setting Setting, value string) error
 | 
			
		||||
	UpdateSubject(userId int64, subject string) error
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) InitializeSettingsMap() error {
 | 
			
		||||
@ -126,6 +138,49 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error) {
 | 
			
		||||
	stmt := `INSERT INTO users (ShortId, Username, Email, IsBanned, OIDCSubject, Created)
 | 
			
		||||
    VALUES (?, ?, ?, FALSE, ?, ?)`
 | 
			
		||||
	tx, err := m.DB.Begin()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return -1, err
 | 
			
		||||
	}
 | 
			
		||||
	result, err := tx.Exec(stmt, shortId, username, email, subject, time.Now().UTC())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if rollbackErr := tx.Rollback(); rollbackErr != nil {
 | 
			
		||||
			return -1, err
 | 
			
		||||
		}
 | 
			
		||||
		if sqliteError, ok := err.(sqlite3.Error); ok {
 | 
			
		||||
			if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") {
 | 
			
		||||
				return -1, ErrDuplicateEmail
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return -1, err
 | 
			
		||||
	}
 | 
			
		||||
	id, err := result.LastInsertId()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if rollbackErr := tx.Rollback(); rollbackErr != nil {
 | 
			
		||||
			return -1, err
 | 
			
		||||
		}
 | 
			
		||||
		return -1, err
 | 
			
		||||
	}
 | 
			
		||||
	stmt = `INSERT INTO user_settings (UserId, SettingId, AllowedSettingValueId, UnconstrainedValue) 
 | 
			
		||||
		VALUES (?, ?, ?, ?)`
 | 
			
		||||
	_, err = tx.Exec(stmt, id, m.Settings[SettingUserTimezone].id, nil, settings.LocalTimezone.String())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if rollbackErr := tx.Rollback(); rollbackErr != nil {
 | 
			
		||||
			return -1, err
 | 
			
		||||
		}
 | 
			
		||||
		return -1, err
 | 
			
		||||
	}
 | 
			
		||||
	err = tx.Commit()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return -1, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return id, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) Get(shortId uint64) (User, error) {
 | 
			
		||||
	stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND Deleted IS NULL`
 | 
			
		||||
	tx, err := m.DB.Begin()
 | 
			
		||||
@ -239,6 +294,44 @@ func (m *UserModel) Authenticate(email, password string) (int64, error) {
 | 
			
		||||
	return id, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) GetByEmail(email string) (int64, error) {
 | 
			
		||||
	var id int64
 | 
			
		||||
	stmt := `SELECT Id FROM users WHERE Email = ?`
 | 
			
		||||
	err := m.DB.QueryRow(stmt, email).Scan(&id)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
			return -1, ErrNoRecord
 | 
			
		||||
		} else {
 | 
			
		||||
			return -1, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return id, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) GetBySubject(subject string) (int64, error) {
 | 
			
		||||
	var id int64
 | 
			
		||||
	var s sql.NullString
 | 
			
		||||
	stmt := `SELECT Id, OIDCSubject FROM users WHERE OIDCSubject = ?`
 | 
			
		||||
	err := m.DB.QueryRow(stmt, subject).Scan(&id, &s)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		if errors.Is(err, sql.ErrNoRows) {
 | 
			
		||||
			return -1, ErrNoRecord
 | 
			
		||||
		} else {
 | 
			
		||||
			return -1, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return id, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) UpdateSubject(userId int64, subject string) error {
 | 
			
		||||
	stmt := `UPDATE users SET OIDCSubject = ? WHERE Id = ?`
 | 
			
		||||
	_, err := m.DB.Exec(stmt, subject, userId)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m *UserModel) Exists(id int64) (bool, error) {
 | 
			
		||||
	var exists bool
 | 
			
		||||
	stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND DELETED IS NULL)`
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										4
									
								
								migrations/000006_change_password_field.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/000006_change_password_field.down.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
			
		||||
ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld;
 | 
			
		||||
ALTER TABLE users ADD COLUMN HashedPassword char(60) NOT NULL DEFAULT '0000';
 | 
			
		||||
UPDATE users SET HashedPassword=HashedPasswordOld;
 | 
			
		||||
ALTER TABLE users DROP COLUMN HashedPasswordOld;
 | 
			
		||||
							
								
								
									
										4
									
								
								migrations/000006_change_password_field.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/000006_change_password_field.up.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,4 @@
 | 
			
		||||
ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld;
 | 
			
		||||
ALTER TABLE users ADD COLUMN HashedPassword char(60) NULL;
 | 
			
		||||
UPDATE users SET HashedPassword=HashedPasswordOld;
 | 
			
		||||
ALTER TABLE users DROP COLUMN HashedPasswordOld;
 | 
			
		||||
							
								
								
									
										1
									
								
								migrations/000007_add_oidc_subject.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/000007_add_oidc_subject.down.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
ALTER TABLE users DROP COLUMN OIDCSubject;
 | 
			
		||||
							
								
								
									
										1
									
								
								migrations/000007_add_oidc_subject.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/000007_add_oidc_subject.up.sql
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
ALTER TABLE users ADD COLUMN OIDCSubject varchar(255);
 | 
			
		||||
@ -6,12 +6,15 @@ import "fmt"
 | 
			
		||||
import "strings"
 | 
			
		||||
 | 
			
		||||
type CommonData struct {
 | 
			
		||||
	CurrentYear     int
 | 
			
		||||
	Flash           string
 | 
			
		||||
	IsAuthenticated bool
 | 
			
		||||
	CSRFToken       string
 | 
			
		||||
	CurrentUser     *models.User
 | 
			
		||||
	IsHtmx          bool
 | 
			
		||||
	CurrentYear      int
 | 
			
		||||
	Flash            string
 | 
			
		||||
	IsAuthenticated  bool
 | 
			
		||||
	CSRFToken        string
 | 
			
		||||
	CurrentUser      *models.User
 | 
			
		||||
	IsHtmx           bool
 | 
			
		||||
	RootUrl          string
 | 
			
		||||
	LocalAuthEnabled bool
 | 
			
		||||
	OIDCEnabled      bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func shortIdToSlug(shortId uint64) string {
 | 
			
		||||
@ -51,7 +54,9 @@ templ topNav(data CommonData) {
 | 
			
		||||
				<a href="/users/settings">Settings</a> | 
 | 
			
		||||
				<a href="#" hx-post="/users/logout" hx-headers={ hxHeaders }>Logout</a>
 | 
			
		||||
			} else {
 | 
			
		||||
				<a href="/users/register">Create an Account</a> | 
 | 
			
		||||
				if data.LocalAuthEnabled {
 | 
			
		||||
					<a href="/users/register">Create an Account</a> | 
 | 
			
		||||
				}
 | 
			
		||||
				<a href="/users/login">Login</a>
 | 
			
		||||
			}
 | 
			
		||||
		</div>
 | 
			
		||||
 | 
			
		||||
@ -14,13 +14,15 @@ import "fmt"
 | 
			
		||||
import "strings"
 | 
			
		||||
 | 
			
		||||
type CommonData struct {
 | 
			
		||||
	CurrentYear     int
 | 
			
		||||
	Flash           string
 | 
			
		||||
	IsAuthenticated bool
 | 
			
		||||
	CSRFToken       string
 | 
			
		||||
	CurrentUser     *models.User
 | 
			
		||||
	IsHtmx          bool
 | 
			
		||||
	RootUrl         string
 | 
			
		||||
	CurrentYear      int
 | 
			
		||||
	Flash            string
 | 
			
		||||
	IsAuthenticated  bool
 | 
			
		||||
	CSRFToken        string
 | 
			
		||||
	CurrentUser      *models.User
 | 
			
		||||
	IsHtmx           bool
 | 
			
		||||
	RootUrl          string
 | 
			
		||||
	LocalAuthEnabled bool
 | 
			
		||||
	OIDCEnabled      bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func shortIdToSlug(shortId uint64) string {
 | 
			
		||||
@ -102,7 +104,7 @@ func topNav(data CommonData) templ.Component {
 | 
			
		||||
			var templ_7745c5c3_Var3 string
 | 
			
		||||
			templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentUser.Username)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 44, Col: 40}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 47, Col: 40}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
@ -121,7 +123,7 @@ func topNav(data CommonData) templ.Component {
 | 
			
		||||
			var templ_7745c5c3_Var4 string
 | 
			
		||||
			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(hxHeaders)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 52, Col: 62}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 55, Col: 62}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
@ -132,12 +134,18 @@ func topNav(data CommonData) templ.Component {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"/users/register\">Create an Account</a> |  <a href=\"/users/login\">Login</a>")
 | 
			
		||||
			if data.LocalAuthEnabled {
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"/users/register\">Create an Account</a> | ")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " <a href=\"/users/login\">Login</a>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></nav>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></nav>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
@ -166,7 +174,7 @@ func commonFooter() templ.Component {
 | 
			
		||||
			templ_7745c5c3_Var5 = templ.NopComponent
 | 
			
		||||
		}
 | 
			
		||||
		ctx = templ.ClearChildren(ctx)
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<footer><p>A <a href=\"https://32bit.cafe\">32bit.cafe</a> Project</p></footer>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<footer><p>A <a href=\"https://32bit.cafe\">32bit.cafe</a> Project</p></footer>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
@ -195,20 +203,20 @@ func base(title string, data CommonData) templ.Component {
 | 
			
		||||
			templ_7745c5c3_Var6 = templ.NopComponent
 | 
			
		||||
		}
 | 
			
		||||
		ctx = templ.ClearChildren(ctx)
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!doctype html><html lang=\"en\"><head><title>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!doctype html><html lang=\"en\"><head><title>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
		var templ_7745c5c3_Var7 string
 | 
			
		||||
		templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 71, Col: 17}
 | 
			
		||||
			return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 76, Col: 17}
 | 
			
		||||
		}
 | 
			
		||||
		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/classless.min.css\" rel=\"stylesheet\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/classless.min.css\" rel=\"stylesheet\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
@ -220,25 +228,25 @@ func base(title string, data CommonData) templ.Component {
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<main>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<main>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
		if data.Flash != "" {
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"flash\">")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"flash\">")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var8 string
 | 
			
		||||
			templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Flash)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 83, Col: 36}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 88, Col: 36}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
@ -247,7 +255,7 @@ func base(title string, data CommonData) templ.Component {
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</main>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</main>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
@ -255,7 +263,7 @@ func base(title string, data CommonData) templ.Component {
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>")
 | 
			
		||||
		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>")
 | 
			
		||||
		if templ_7745c5c3_Err != nil {
 | 
			
		||||
			return templ_7745c5c3_Err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -30,7 +30,10 @@ templ UserLogin(title string, data CommonData, form forms.UserLoginForm) {
 | 
			
		||||
				<input type="password" name="password"/>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div>
 | 
			
		||||
				<input type="submit" value="login"/>
 | 
			
		||||
				<input type="submit" value="Login"/>
 | 
			
		||||
				if data.OIDCEnabled {
 | 
			
		||||
					<a href="/users/login/oidc">Login with SSO</a>
 | 
			
		||||
				}
 | 
			
		||||
			</div>
 | 
			
		||||
		</form>
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -143,7 +143,17 @@ func UserLogin(title string, data CommonData, form forms.UserLoginForm) templ.Co
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"login\"></div></form>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"Login\"> ")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			if data.OIDCEnabled {
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"/users/login/oidc\">Login with SSO</a>")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></form>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
@ -190,130 +200,130 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration
 | 
			
		||||
				}()
 | 
			
		||||
			}
 | 
			
		||||
			ctx = templ.InitializeContext(ctx)
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var10 string
 | 
			
		||||
			templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 43, Col: 64}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 46, Col: 64}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><div>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><div>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			error, exists := form.FieldErrors["name"]
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"username\">Username: </label> ")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<label for=\"username\">Username: </label> ")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			if exists {
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<label class=\"error\">")
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label class=\"error\">")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
				var templ_7745c5c3_Var11 string
 | 
			
		||||
				templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(error)
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 48, Col: 33}
 | 
			
		||||
					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 51, Col: 33}
 | 
			
		||||
				}
 | 
			
		||||
				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</label> ")
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</label> ")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<input type=\"text\" name=\"username\" id=\"username\" value=\"")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<input type=\"text\" name=\"username\" id=\"username\" value=\"")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var12 string
 | 
			
		||||
			templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 50, Col: 70}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 53, Col: 70}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" required></div><div>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" required></div><div>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			error, exists = form.FieldErrors["email"]
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<label for=\"email\">Email: </label> ")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"email\">Email: </label> ")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			if exists {
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<label class=\"error\">")
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<label class=\"error\">")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
				var templ_7745c5c3_Var13 string
 | 
			
		||||
				templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(error)
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 56, Col: 33}
 | 
			
		||||
					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 59, Col: 33}
 | 
			
		||||
				}
 | 
			
		||||
				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</label> ")
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</label> ")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<input type=\"text\" name=\"email\" id=\"email\" value=\"")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<input type=\"text\" name=\"email\" id=\"email\" value=\"")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var14 string
 | 
			
		||||
			templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 58, Col: 65}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 61, Col: 65}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" required></div><div>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></div><div>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			error, exists = form.FieldErrors["password"]
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<label for=\"password\">Password: </label> ")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<label for=\"password\">Password: </label> ")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			if exists {
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<label class=\"error\">")
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label class=\"error\">")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
				var templ_7745c5c3_Var15 string
 | 
			
		||||
				templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(error)
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 64, Col: 33}
 | 
			
		||||
					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 67, Col: 33}
 | 
			
		||||
				}
 | 
			
		||||
				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</label> ")
 | 
			
		||||
				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</label> ")
 | 
			
		||||
				if templ_7745c5c3_Err != nil {
 | 
			
		||||
					return templ_7745c5c3_Err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
@ -360,33 +370,33 @@ func UserProfile(title string, data CommonData, user models.User) templ.Componen
 | 
			
		||||
				}()
 | 
			
		||||
			}
 | 
			
		||||
			ctx = templ.InitializeContext(ctx)
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<h1>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<h1>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var18 string
 | 
			
		||||
			templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 77, Col: 21}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 80, Col: 21}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</h1><p>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var19 string
 | 
			
		||||
			templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 78, Col: 17}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 81, Col: 17}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</p>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
@ -434,89 +444,89 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component {
 | 
			
		||||
				}()
 | 
			
		||||
			}
 | 
			
		||||
			ctx = templ.InitializeContext(ctx)
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div><h1>User Settings</h1><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div><h1>User Settings</h1><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			var templ_7745c5c3_Var22 string
 | 
			
		||||
			templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken)
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 88, Col: 65}
 | 
			
		||||
				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 91, Col: 65}
 | 
			
		||||
			}
 | 
			
		||||
			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"> <label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"> <label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
			for _, tz := range timezones {
 | 
			
		||||
				if tz == user.Settings.LocalTimezone.String() {
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<option value=\"")
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<option value=\"")
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					var templ_7745c5c3_Var23 string
 | 
			
		||||
					templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 93, Col: 25}
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 96, Col: 25}
 | 
			
		||||
					}
 | 
			
		||||
					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" selected=\"true\">")
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" selected=\"true\">")
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					var templ_7745c5c3_Var24 string
 | 
			
		||||
					templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 93, Col: 48}
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 96, Col: 48}
 | 
			
		||||
					}
 | 
			
		||||
					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</option>")
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</option>")
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
				} else {
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<option value=\"")
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<option value=\"")
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					var templ_7745c5c3_Var25 string
 | 
			
		||||
					templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 95, Col: 25}
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 98, Col: 25}
 | 
			
		||||
					}
 | 
			
		||||
					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">")
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">")
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					var templ_7745c5c3_Var26 string
 | 
			
		||||
					templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tz)
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 95, Col: 32}
 | 
			
		||||
						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 98, Col: 32}
 | 
			
		||||
					}
 | 
			
		||||
					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</option>")
 | 
			
		||||
					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</option>")
 | 
			
		||||
					if templ_7745c5c3_Err != nil {
 | 
			
		||||
						return templ_7745c5c3_Err
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</select> <input type=\"submit\" value=\"Submit\"></form></div>")
 | 
			
		||||
			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</select> <input type=\"submit\" value=\"Submit\"></form></div>")
 | 
			
		||||
			if templ_7745c5c3_Err != nil {
 | 
			
		||||
				return templ_7745c5c3_Err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user