Compare commits

...
33 Commits
Author SHA1 Message Date
gawells ac872d5127 Display calendar page with name of login, also add in README.md 2026-08-06 20:34:17 -04:00
gawells ec901d97c2 change user endpoint to calandar 2026-08-06 20:25:44 -04:00
gawells a0c57bd582 create new OAuth provider from settings 2026-08-06 20:19:55 -04:00
gawells 793904e942 setup new provider endpoint creation 2026-08-06 20:19:11 -04:00
gawells fd12ba02fd new provider generation 2026-08-06 20:17:22 -04:00
gawells e6cebed4ba move calendar endpoint into new file 2026-08-06 20:16:55 -04:00
gawells 08ba0130c1 add in config for oauth endpoint 2026-08-06 20:16:10 -04:00
gawells 4a7b3c91f0 im sure I did something 2026-08-06 19:55:51 -04:00
gawells f76d715cb9 get basic information about the user 2026-08-06 19:38:04 -04:00
gawells af2d823705 readd in password auth 2026-08-06 14:54:20 -04:00
gawells 16a38c972c get token from OAuth provider 2026-08-06 14:46:08 -04:00
gawells 4644a7d3f4 Handle state in OAuth client 2026-08-06 14:37:53 -04:00
gawells c305b53f11 actually do some OAuth 2026-08-06 10:08:07 -04:00
gawells 349ab612d6 change scopes 2026-08-04 21:10:30 -04:00
gawells c37a433984 write basic OAuth driver 2026-08-04 21:09:46 -04:00
gawells 8a8ebcdeec make login page date different depending on auth type 2026-08-04 21:07:07 -04:00
gawells ab78dd711f add login with OAuth button to login page 2026-08-03 21:30:19 -04:00
gawells 1fddaf3f67 reformat some stuff 2026-08-03 21:01:58 -04:00
gawells c3bee53ade remove some stale server config info 2026-08-01 21:42:12 -04:00
gawells 9b0dea64fc make an auth request 2026-07-31 13:18:13 -07:00
gawells 848a747fba stop error close button form submitting the form 2026-07-31 13:09:12 -07:00
gawells ff2230e2f7 auth request handler 2026-07-31 13:06:23 -07:00
gawells f63259dcbc add login page customization 2026-07-31 13:01:25 -07:00
gawells f0da8aac87 Get the CSS and statuc stuff to work 2026-07-31 12:57:19 -07:00
gawells ba8afc76cc serve logo and favicon 2026-07-31 12:50:50 -07:00
gawells e53631a65b add login page to webserver 2026-07-31 12:45:43 -07:00
gawells e8b422e390 get very simple webserver up and running 2026-07-31 12:38:56 -07:00
gawells 760dbec077 start adding in webserver component 2026-07-31 12:31:08 -07:00
gawells 9bd19b9cc4 do some math on lengths 2026-07-31 12:17:43 -07:00
gawells 22815cd523 parse events 2026-07-31 12:12:15 -07:00
gawells 539916aaf8 get event URLs 2026-07-30 23:18:35 -07:00
gawells 60bbe43e57 get the basic caldav event data (in freaky XML) 2026-07-27 22:10:48 -07:00
gawells 6574cc1111 get the URL for pasring the dav data 2026-07-27 22:06:16 -07:00
29 changed files with 958 additions and 105 deletions
+1
View File
@@ -1,2 +1,3 @@
config.json
.DS_Store
token.json
+5
View File
@@ -1,3 +1,8 @@
A calandar viewer for stalwart calandars
**NOTE** while this app uses standard caldav reading it makes assumptions about paths and treats them as if they all come from stalwart
**TODO**
1. Switch over to a golang caldav library, while I wanted to roll my own CalDAV I am too lazy to read up on the spec so imma just get someone else to do it
2. Fix password login
3. Actually write a UI that looks somewhat decent
+108
View File
@@ -0,0 +1,108 @@
package auth
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"strings"
"astraltech.xyz/calendar/v2/caldav"
"astraltech.xyz/calendar/v2/webserver"
ics "github.com/arran4/golang-ical"
"golang.org/x/oauth2"
)
var TestTokenStore TokenStore
func SetAuth() {
TestTokenStore = TokenStore{filePath: "token.json"}
}
func generateState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func HandlePasswordAuth(authData webserver.PasswordAuthData) bool {
calDavData := caldav.GetCalDAVData(authData.Username, authData.Password)
fmt.Printf("Users display name: %s\n", calDavData.DisplayName)
fmt.Printf("Calandar Count: %d\n", len(calDavData.Calandars))
for i := range len(calDavData.Calandars) {
calDavData.Calandars[i] = caldav.GetCalendarData(calDavData.Calandars[i], authData.Username, authData.Password)
fmt.Printf("Cal %d is named %s\n", i, calDavData.Calandars[i].DisplayName)
fmt.Printf("\tLink to cal: %s\n", calDavData.Calandars[i].Link)
fmt.Printf("\tEvent count: %d\n", len(calDavData.Calandars[i].Events))
for j := range len(calDavData.Calandars[i].Events) {
cal, err := ics.ParseCalendar(strings.NewReader(calDavData.Calandars[i].Events[j].CalData))
if err != nil {
fmt.Print(err.Error())
}
events := cal.Events()
for k := range len(events) {
name := events[k].GetProperty(ics.ComponentProperty(ics.PropertySummary)).Value
start, _ := events[k].GetStartAt()
end, _ := events[k].GetEndAt()
time := end.Sub(start)
fmt.Printf("\t\tEvent name: %s\n", name)
fmt.Printf("\t\t\tEvent start date: %s\n", end.UTC().String())
fmt.Printf("\t\t\tEvent length date: %s\n", time.String())
}
}
}
return false
}
func HandleOAuth(authData webserver.OAuthAuthData) bool {
state, err := generateState()
if err != nil {
http.Error(*authData.ResponseWriter, "Failed to generate state", http.StatusInternalServerError)
return false
}
verifier := oauth2.GenerateVerifier()
http.SetCookie(*authData.ResponseWriter, &http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
MaxAge: 300,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(*authData.ResponseWriter, &http.Cookie{
Name: "pkce_verifier",
Value: verifier,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
url := DefaultProvider.Config.AuthCodeURL(
state,
oauth2.S256ChallengeOption(verifier),
)
http.Redirect(*authData.ResponseWriter, authData.Request, url, http.StatusFound)
return false
}
func HandleAuthRequest(authType webserver.AuthStyle, authData webserver.AuthData) bool {
if authType == webserver.AuthStylePassword {
passwordAuthData, _ := authData.(webserver.PasswordAuthData)
return HandlePasswordAuth(passwordAuthData)
}
if authType == webserver.AuthStyleOAuth {
passwordAuthData, _ := authData.(webserver.OAuthAuthData)
return HandleOAuth(passwordAuthData)
}
return false
}
+49
View File
@@ -0,0 +1,49 @@
package auth
import (
"context"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// This is all the user info that this script needs
type UserInfo struct {
Email string `json:"email"`
Name string `json:"name"`
}
type OAuthProvider struct {
Provider *oidc.Provider
Config oauth2.Config
}
var Providers []OAuthProvider
var DefaultProvider *OAuthProvider // Assumed to be the last created provider
func CreateOAuthProvider(issuer string, clientID string) error {
ctx := context.Background()
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return err
}
OAuthConfig := oauth2.Config{
ClientID: clientID,
RedirectURL: "http://localhost:8080/callback",
Endpoint: provider.Endpoint(),
Scopes: []string{
oidc.ScopeOpenID,
"profile",
"email",
},
}
newProvider := OAuthProvider{
Provider: provider,
Config: OAuthConfig,
}
Providers = append(Providers, newProvider)
DefaultProvider = &newProvider
return nil
}
+52
View File
@@ -0,0 +1,52 @@
package auth
import (
"context"
"log"
"net/http"
"golang.org/x/oauth2"
)
// this is usually served at /callback
func OAuthCallback(w http.ResponseWriter, r *http.Request) {
state := r.FormValue("state")
code := r.FormValue("code")
stateCookie, err := r.Cookie("oauth_state")
if err != nil {
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
return
}
if state != stateCookie.Value {
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: "oauth_state",
Value: "",
Path: "/",
MaxAge: -1,
})
verifierCookie, err := r.Cookie("pkce_verifier")
if err != nil {
http.Error(w, "Missing PKCE verifier", http.StatusBadRequest)
return
}
verifier := verifierCookie.Value
token, err := DefaultProvider.Config.Exchange(context.Background(), code, oauth2.VerifierOption(verifier))
if err != nil {
http.Error(w, "Failed to exchange token: "+err.Error(), http.StatusInternalServerError)
return
}
if err := TestTokenStore.Save(token); err != nil {
log.Printf("Failed to save token: %v", err)
}
http.Redirect(w, r, "/calandar", http.StatusTemporaryRedirect)
}
+32
View File
@@ -0,0 +1,32 @@
package auth
import (
"encoding/json"
"os"
"golang.org/x/oauth2"
)
type TokenStore struct {
filePath string
}
func (ts *TokenStore) Save(token *oauth2.Token) error {
data, err := json.Marshal(token)
if err != nil {
return err
}
return os.WriteFile(ts.filePath, data, 0600)
}
func (ts *TokenStore) Load() (*oauth2.Token, error) {
data, err := os.ReadFile(ts.filePath)
if err != nil {
return nil, err
}
var token oauth2.Token
if err := json.Unmarshal(data, &token); err != nil {
return nil, err
}
return &token, nil
}
@@ -1,4 +1,4 @@
package main
package caldav
type MultiStatus struct {
Responses []Response `xml:"response"`
@@ -17,6 +17,7 @@ type PropStat struct {
type Prop struct {
DisplayName string `xml:"displayname"`
Type ResourceType `xml:"resourcetype"`
CalendarData string `xml:"calendar-data"`
}
type ResourceType struct {
+147
View File
@@ -0,0 +1,147 @@
package caldav
import (
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"strings"
)
// simple test XML
const neededXML string = `
<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
</d:prop>
</d:propfind>
`
var BuildInClient *http.Client
var serverURL string
func InitDavClient(url string) {
BuildInClient = &http.Client{}
serverURL = url
}
type Event struct {
CalData string
}
type Calandar struct {
DisplayName string
Link string
Events []Event
}
type SimpleCalDavData struct {
DisplayName string
Calandars []Calandar
}
func GetCalDAVData(username string, password string) SimpleCalDavData {
req, _ := http.NewRequest(
"PROPFIND",
serverURL+"/dav/cal/"+convert_username_into_url(username),
strings.NewReader(neededXML),
)
req.SetBasicAuth(username, password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml")
resp, err := BuildInClient.Do(req)
if err != nil {
fmt.Print(err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Print(err.Error())
}
var davResp MultiStatus
err = xml.Unmarshal(body, &davResp)
if err != nil {
fmt.Print(err)
}
calandars := []Calandar{}
for i := range len(davResp.Responses) {
if davResp.Responses[i].PropStat[0].Prop.Type.Calendar == nil {
continue
}
var new_cal Calandar = Calandar{
DisplayName: davResp.Responses[i].PropStat[0].Prop.DisplayName,
Link: serverURL + davResp.Responses[i].Href,
}
calandars = append(calandars, new_cal)
}
return SimpleCalDavData{
DisplayName: davResp.Responses[0].PropStat[0].Prop.DisplayName,
Calandars: calandars,
}
}
const XML_cal string = `
<C:calendar-query xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<C:filter>
<C:comp-filter name="VCALENDAR">
<C:comp-filter name="VEVENT"/>
</C:comp-filter>
</C:filter>
</C:calendar-query>
`
func GetCalendarData(cal Calandar, username string, password string) Calandar {
req, _ := http.NewRequest(
"REPORT",
cal.Link,
strings.NewReader(XML_cal),
)
req.SetBasicAuth(username, password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml")
resp, err := BuildInClient.Do(req)
if err != nil {
fmt.Print(err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
var davResp MultiStatus
err = xml.Unmarshal(body, &davResp)
if err != nil {
log.Fatal(err)
}
for i := range len(davResp.Responses) {
newEvent := Event{
CalData: davResp.Responses[i].PropStat[0].Prop.CalendarData,
}
cal.Events = append(cal.Events, newEvent)
}
return cal
}
+1 -1
View File
@@ -1,4 +1,4 @@
package main
package caldav
import "strings"
+4 -3
View File
@@ -1,5 +1,6 @@
{
"url:": "https://mx.example.com",
"username": "admin@example.com",
"password": "password"
"caldav_url:": "https://mx.example.com",
"oauth_enabled": true,
"oauth_issuer": "https://sso.example.com/application/o/stalwart/",
"oauth_clientid": "stalwart-webui"
}
+10
View File
@@ -0,0 +1,10 @@
module astraltech.xyz/calendar/v2
go 1.26.1
require (
github.com/arran4/golang-ical v0.3.5
github.com/coreos/go-oidc/v3 v3.20.0
github.com/go-jose/go-jose/v4 v4.1.4
golang.org/x/oauth2 v0.36.0
)
+8
View File
@@ -0,0 +1,8 @@
github.com/arran4/golang-ical v0.3.5 h1:bbz6ld4dC+MmCKiFfOd6SkmIGnhNMBACZ485ULh7p9A=
github.com/arran4/golang-ical v0.3.5/go.mod h1:OnguFgjN0Hmx8jzpmWcC+AkHio94ujmLHKoaef7xQh8=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+52
View File
@@ -0,0 +1,52 @@
package main
import (
"context"
"encoding/json"
"net/http"
"text/template"
"astraltech.xyz/calendar/v2/auth"
)
type CalendarData struct {
DisplayName string
}
func ParseOAuthLogin(w http.ResponseWriter, r *http.Request) CalendarData {
token, err := auth.TestTokenStore.Load()
if err != nil {
http.Error(w, "Not authenticated", http.StatusUnauthorized)
return CalendarData{}
}
client := auth.DefaultProvider.Config.Client(context.Background(), token)
resp, err := client.Get(auth.DefaultProvider.Provider.UserInfoEndpoint())
if err != nil {
http.Error(w, "Failed to fetch user: "+err.Error(), http.StatusInternalServerError)
return CalendarData{}
}
defer resp.Body.Close()
var usersInfo auth.UserInfo
if err := json.NewDecoder(resp.Body).Decode(&usersInfo); err != nil {
http.Error(w, "Failed to decode response", http.StatusInternalServerError)
return CalendarData{}
}
return CalendarData{
DisplayName: usersInfo.Name,
}
}
func HandleCalandarEndpoint(w http.ResponseWriter, r *http.Request) {
var OauthLogin = true
var data CalendarData
if OauthLogin {
data = ParseOAuthLogin(w, r)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := template.Must(template.ParseFiles("main/pages/calendar.html"))
tmpl.Execute(w, data)
}
+4 -3
View File
@@ -7,9 +7,10 @@ import (
)
type Config struct {
URL string `json:"url"`
Username string `json:"username"`
Password string `json:"password"`
URL string `json:"caldav_url"`
OAuthEnabled bool `json:"oauth_enabled"`
OAuthIssuer string `json:"oauth_issuer"`
OAuthClientID string `json:"oauth_clientid"`
}
var serverConfig Config
-86
View File
@@ -1,86 +0,0 @@
package main
import (
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"strings"
)
// simple test XML
const neededXML string = `
<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:">
<d:prop>
<d:displayname/>
<d:resourcetype/>
</d:prop>
</d:propfind>
`
var client *http.Client
func init_dav_client() {
client = &http.Client{}
}
type Calandar struct {
DisplayName string
}
type SimpleCalDavData struct {
DisplayName string
Calandars []Calandar
}
// this makes stalwart assumptions
func get_caldav_data(username string, password string) SimpleCalDavData {
req, _ := http.NewRequest(
"PROPFIND",
serverConfig.URL+"/dav/cal/"+convert_username_into_url(username),
strings.NewReader(neededXML),
)
req.SetBasicAuth(username, password)
req.Header.Set("Depth", "1")
req.Header.Set("Content-Type", "application/xml")
resp, err := client.Do(req)
if err != nil {
fmt.Print(err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
var davResp MultiStatus
err = xml.Unmarshal(body, &davResp)
if err != nil {
log.Fatal(err)
}
fmt.Print(string(body))
calandars := []Calandar{}
for i := range len(davResp.Responses) {
if davResp.Responses[i].PropStat[0].Prop.Type.Calendar == nil {
continue
}
var new_cal Calandar = Calandar{
DisplayName: davResp.Responses[i].PropStat[0].Prop.DisplayName,
}
calandars = append(calandars, new_cal)
}
return SimpleCalDavData{
DisplayName: davResp.Responses[0].PropStat[0].Prop.DisplayName,
Calandars: calandars,
}
}
+21 -9
View File
@@ -1,17 +1,29 @@
package main
import "fmt"
import (
"astraltech.xyz/calendar/v2/auth"
"astraltech.xyz/calendar/v2/caldav"
"astraltech.xyz/calendar/v2/webserver"
)
func main() {
auth.SetAuth()
read_config()
init_dav_client()
calDavData := get_caldav_data(serverConfig.Username, serverConfig.Password)
fmt.Println()
fmt.Printf("Users display name: %s\n", calDavData.DisplayName)
fmt.Printf("Calandar Count: %d\n", len(calDavData.Calandars))
for i := range len(calDavData.Calandars) {
fmt.Printf("Cal %d is named %s\n", i, calDavData.Calandars[i].DisplayName)
if serverConfig.OAuthEnabled {
auth.CreateOAuthProvider(serverConfig.OAuthIssuer, serverConfig.OAuthClientID)
}
caldav.InitDavClient(serverConfig.URL)
webserver.ServeLoginPage("/login", webserver.CustomizableLoginData{
ServiceName: "Astral Calendar",
AuthRequestFunction: auth.HandleAuthRequest,
})
webserver.ServeWebpage("/callback", auth.OAuthCallback)
webserver.ServeWebpage("/calandar", HandleCalandarEndpoint)
webserver.EnableLogoRoute()
webserver.EnableStaticRoute()
webserver.ServeWebserver()
}
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Astral Calendar</title>
</head>
<body>
Welcome to your calendar {{.DisplayName}}
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

+4
View File
@@ -0,0 +1,4 @@
this is meant to be a reusable webserver component that I can integrate into applications
while it can serve custom routes it is also designed to server routes that are needed in all of my applications
Examples:
- Login page
+74
View File
@@ -0,0 +1,74 @@
package webserver
import (
"net/http"
"strings"
"text/template"
)
type LoginPageData struct {
IsHiddenClassList string
LoginData CustomizableLoginData
}
type AuthStyle int
const (
AuthStylePassword AuthStyle = 0
AuthStyleOAuth AuthStyle = 1
)
type AuthData interface{}
type PasswordAuthData struct {
Username string
Password string
}
type OAuthAuthData struct {
ResponseWriter *http.ResponseWriter
Request *http.Request
}
type CustomizableLoginData struct {
ServiceName string
AuthRequestFunction func(AuthStyle, AuthData) bool
}
var LoginPageDataCustomizations CustomizableLoginData
func loginHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := template.Must(template.ParseFiles("webserver/pages/login_page.html"))
if r.Method == http.MethodGet {
tmpl.Execute(w, LoginPageData{IsHiddenClassList: "hidden", LoginData: LoginPageDataCustomizations})
return
}
if r.Method == http.MethodPost {
action := r.FormValue("action")
if action == "password" {
username := r.FormValue("username")
if strings.Contains(username, "/") {
tmpl.Execute(w, LoginPageData{IsHiddenClassList: "", LoginData: LoginPageDataCustomizations})
}
password := r.FormValue("password")
auth_success := LoginPageDataCustomizations.AuthRequestFunction(AuthStylePassword, PasswordAuthData{
Username: username,
Password: password,
})
if auth_success == false {
tmpl.Execute(w, LoginPageData{IsHiddenClassList: "", LoginData: LoginPageDataCustomizations})
}
} else {
auth_success := LoginPageDataCustomizations.AuthRequestFunction(AuthStyleOAuth, OAuthAuthData{
ResponseWriter: &w,
Request: r,
})
if auth_success == false {
tmpl.Execute(w, LoginPageData{IsHiddenClassList: "", LoginData: LoginPageDataCustomizations})
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<title>Astral Tech - Login</title>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="static/style.css" />
<link rel="stylesheet" href="static/error/error.css" />
<link rel="stylesheet" href="static/card.css" />
<link rel="stylesheet" href="static/login_page.css" />
<img id="logo_image" alt="logo" src="/logo" />
<form id="login_card" class="card" method="POST">
<div id="welcome_text">
Welcome to {{.LoginData.ServiceName}}, Please Sign in to your account below
</div>
<div class="error {{.IsHiddenClassList}}">
⚠️ Invalid username or password.
<button class="close_error_button" type="button">X</button>
</div>
<div>
<label class="input_label">Username</label><br />
<input type="text" name="username" placeholder="" />
</div>
<div>
<label class="input_label">Password</label><br />
<div class="password_box">
<input type="password" name="password" placeholder="" />
<button type="button" class="show_password_toggle closed"></button>
</div>
</div>
<div id="login_buttons">
<button type="submit" name="action" value="password">Login with password</button>
<button type="submit" id="login_with_oauth" name="action" value="oauth"><img src="/favicon.ico"/></button>
</div>
</form>
<script src="/static/error/error.js" type="text/javascript"></script>
<script
src="/static/javascript/show_password.js"
type="text/javascript"
></script>
+20
View File
@@ -0,0 +1,20 @@
.card {
background: rgba(255, 255, 255, 1);
border: 1px solid var(--border-subtle);
border-radius: 12px;
padding: 2.5rem;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.03);
transition: transform 0.2s ease;
display: flex;
flex-direction: column;
gap: 15px;
overflow: hidden;
}
.static_center {
position: fixed;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
}
+30
View File
@@ -0,0 +1,30 @@
.error {
background-color: var(--error-red) !important;
border-radius: 10px;
padding: 20px;
border-style: solid;
border-color: var(--error-border-red);
border-width: 1px;
box-sizing: border-box;
font-size: 12px;
position: relative;
color: white;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
}
.close_error_button {
position: absolute !important;
background-color: rgba(0, 0, 0, 0) !important;
padding: 0px !important;
top: 20px;
right: 20px;
color: black !important;
font-weight: normal !important;
border: none;
width: fit-content !important;
}
.close_error_button:hover {
cursor: default;
font-weight: bold !important;
}
+7
View File
@@ -0,0 +1,7 @@
var error_close_buttons = document.getElementsByClassName("close_error_button");
for (const close_button of error_close_buttons) {
close_button.addEventListener("click", function () {
this.parentElement.classList.add("hidden");
});
}
@@ -0,0 +1,22 @@
const showPasswordButtons = document.getElementsByClassName(
"show_password_toggle",
);
for (const button of showPasswordButtons) {
button.addEventListener("click", function () {
const input = this.parentElement.querySelector(
"input[type='password'], input[type='text']",
);
if (!input) return;
const isHidden = input.type === "password";
input.type = isHidden ? "text" : "password";
if (isHidden) {
this.classList.add("open");
this.classList.remove("closed");
} else {
this.classList.remove("open");
this.classList.add("closed");
}
});
}
+55
View File
@@ -0,0 +1,55 @@
#login_card {
position: fixed;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
width: 350px;
}
#login_card input {
width: 324px;
}
#login_card div {
width: 100%;
}
#action-buttons {
justify-content: center;
display: flex;
gap: 15px;
}
#signup_button {
flex: 1;
}
#login_button {
flex: 2;
}
#welcome_text {
font-weight: 700;
font-size: 1.25rem;
margin-bottom: 8px;
}
#login_buttons {
display: grid;
grid-gap: 5px;
grid-template-columns: 1fr 43px;
}
#login_with_oauth {
padding: 0 !important;
position: relative;
}
#login_with_oauth img {
position: absolute;
width: 30px;
height: 30px;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
}
+151
View File
@@ -0,0 +1,151 @@
:root {
/* Backgrounds */
--bg-main: #f7fff7; /* Soft Mint White */
--bg-card: #ffffff; /* Pure White Card */
--bg-input: #f0f4f8; /* Light Gray-Blue Inset */
/* Text & Lines */
--text-main: #1a202c; /* Deep Charcoal (Better than pure black) */
--text-muted: #718096; /* Cool Gray for placeholders */
--border-subtle: #e2e8f0; /* Light Gray border */
/* Action Colors */
--primary-accent: #1a535c; /* Deep Teal (Trustworthy/Secure) */
--primary-hover: #14434a; /* Darker Teal for hover */
--error-red: #ef4444; /* Professional Red */
--error-border-red: #e22d2d;
--warning-yellow: #fef08a;
--warning-border-yellow: #fde047;
--password-strength-weak: var(--error-red);
--password-strength-medium: var(--warning-border-yellow);
--password-strength-strong: #43ef6b;
font-family: "Inter", sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background-color: var(--bg-main);
}
button {
padding: 12px;
background-color: var(--primary-accent); /* Our Teal/Blue */
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
width: 100%;
}
button:hover {
background-color: var(--primary-hover);
}
button:focus {
outline: 2px solid var(--primary-accent);
}
input {
padding: 12px;
background-color: var(--bg-input);
border: 1px solid var(--border-subtle);
color: var(--text-main);
border-radius: 6px;
}
input:focus {
outline: 2px solid var(--primary-accent);
}
input::placeholder {
color: var(--text-muted);
}
.input_label {
color: var(--text-muted);
margin: 0;
padding: 0;
font-size: 15px;
}
.hidden {
display: none !important;
}
.blocked {
position: fixed; /* or absolute */
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999; /* higher = on top */
backdrop-filter: blur(4px);
background-color: rgba(0, 0, 0, 0.3);
pointer-events: all;
}
.dialouge_title {
font-size: 20px;
margin-bottom: 0px;
color: var(--text-main) !important;
}
.password_box {
position: relative;
display: inline-block;
}
.show_password_toggle {
width: 10px;
height: 10px;
position: absolute;
right: 5px;
bottom: 50%;
transform: translateY(50%);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: rgba(0, 0, 0, 0);
}
.show_password_toggle.closed {
background-image: url("/static/images/closed_eye.png");
}
.show_password_toggle.open {
background-image: url("/static/images/filled_eye.png");
}
.show_password_toggle:hover {
background-color: rgba(0, 0, 0, 0);
}
.show_password_toggle:focus {
outline: none !important;
}
#logo_image {
position: fixed;
width: 300px;
left: 50%;
transform: translateX(-50%);
}
.subtext {
color: var(--text-muted);
}
.noselect {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
+40
View File
@@ -0,0 +1,40 @@
package webserver
import (
"fmt"
"net/http"
)
var routes []string
func ServeLoginPage(route string, data CustomizableLoginData) {
LoginPageDataCustomizations = data
ServeWebpage(route, loginHandler)
}
func EnableLogoRoute() {
ServeWebpage("/logo", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "webserver/images/astraltech_logo.png")
})
ServeWebpage("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "webserver/images/astraltech_favicon.png")
})
}
func EnableStaticRoute() {
http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("webserver/static"))))
routes = append(routes, "/static/*")
}
func ServeWebpage(url string, handler func(http.ResponseWriter, *http.Request)) {
http.HandleFunc(url, handler)
routes = append(routes, url)
}
func ServeWebserver() {
err := http.ListenAndServe(":8080", nil)
if err != nil {
fmt.Print(err.Error())
}
}