package main import ( "embed" "errors" "html/template" "log/slog" "net/http" "atlas9.dev/c/core/routes" ) //go:embed templates/*.html var templateFiles embed.FS type OAuthLink struct { Name string URL string } type Pages struct { Identity *IdentityService OAuthLinks []OAuthLink loginTmpl *template.Template registerTmpl *template.Template profileTmpl *template.Template forgotPasswordTmpl *template.Template resetPasswordTmpl *template.Template } func NewPages(identity *IdentityService, oauthLinks []OAuthLink) *Pages { layout := template.Must(template.ParseFS(templateFiles, "templates/layout.html")) parse := func(name string) *template.Template { return template.Must(template.Must(layout.Clone()).ParseFS(templateFiles, "templates/"+name)) } return &Pages{ Identity: identity, OAuthLinks: oauthLinks, loginTmpl: parse("login.html"), registerTmpl: parse("register.html"), profileTmpl: parse("profile.html"), forgotPasswordTmpl: parse("forgot_password.html"), resetPasswordTmpl: parse("reset_password.html"), } } func PageRoutes(p *Pages) []routes.Route { return []routes.Route{ routes.HTTP("GET /{$}", p.handleIndex), routes.HTTP("GET /login", p.handleLoginPage), routes.HTTP("POST /login", p.handleLogin), routes.HTTP("GET /register", p.handleRegisterPage), routes.HTTP("POST /register", p.handleRegister), routes.HTTP("GET /profile", p.handleProfile), routes.HTTP("POST /profile", p.handleProfileUpdate), routes.HTTP("POST /logout", p.handleLogout), routes.HTTP("GET /verify", p.handleVerify), routes.HTTP("POST /resend-verification", p.handleResendVerification), routes.HTTP("GET /forgot-password", p.handleForgotPasswordPage), routes.HTTP("POST /forgot-password", p.handleForgotPassword), routes.HTTP("GET /reset-password", p.handleResetPasswordPage), routes.HTTP("POST /reset-password", p.handleResetPassword), } } type loginData struct { Title string Error string Success string Email string ShowResend bool OAuthLinks []OAuthLink } type registerData struct { Title string Error string } type profileData struct { Title string Email string Name string Bio string Location string Website string PictureURL string Success string Error string } type forgotPasswordData struct { Title string Error string Success string } type resetPasswordData struct { Title string Error string Token string } func (p *Pages) render(w http.ResponseWriter, tmpl *template.Template, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := tmpl.ExecuteTemplate(w, "layout", data); err != nil { http.Error(w, "Internal Server Error", http.StatusInternalServerError) slog.Error("template render error", "err", err) } } func (p *Pages) handleIndex(w http.ResponseWriter, r *http.Request) { _, _, err := p.Identity.GetProfile(r.Context()) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } http.Redirect(w, r, "/profile", http.StatusSeeOther) } func (p *Pages) handleLoginPage(w http.ResponseWriter, r *http.Request) { data := loginData{Title: "Login", OAuthLinks: p.OAuthLinks} if r.URL.Query().Get("registered") == "1" { data.Success = "Registration successful! Please check your email to verify your account." } if r.URL.Query().Get("verified") == "1" { data.Success = "Email verified! Please log in." } if r.URL.Query().Get("reset") == "1" { data.Success = "Password reset! Please log in." } p.render(w, p.loginTmpl, data) } func (p *Pages) handleLogin(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") password := r.FormValue("password") if email == "" || password == "" { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Email and password are required", OAuthLinks: p.OAuthLinks}) return } _, err := p.Identity.Login(r.Context(), email, password) switch { case errors.Is(err, ErrEmailNotVerified): p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Please verify your email before logging in.", Email: email, ShowResend: true, OAuthLinks: p.OAuthLinks}) case err != nil: p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Invalid email or password", OAuthLinks: p.OAuthLinks}) default: http.Redirect(w, r, "/profile", http.StatusSeeOther) } } func (p *Pages) handleRegisterPage(w http.ResponseWriter, r *http.Request) { p.render(w, p.registerTmpl, registerData{Title: "Register"}) } func (p *Pages) handleRegister(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") password := r.FormValue("password") if email == "" || password == "" { p.render(w, p.registerTmpl, registerData{Title: "Register", Error: "Email and password are required"}) return } _, err := p.Identity.Register(r.Context(), email, password) switch { case errors.Is(err, ErrUserExists): p.render(w, p.registerTmpl, registerData{Title: "Register", Error: "An account with that email already exists"}) case err != nil: p.render(w, p.registerTmpl, registerData{Title: "Register", Error: "Something went wrong"}) default: http.Redirect(w, r, "/login?registered=1", http.StatusSeeOther) } } func (p *Pages) handleProfile(w http.ResponseWriter, r *http.Request) { user, profile, err := p.Identity.GetProfile(r.Context()) if err != nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } data := profileData{ Title: "Profile", Email: user.Email, Name: profile.Name, Bio: profile.Bio, Location: profile.Location, Website: profile.Website, PictureURL: profile.PictureURL, } if r.URL.Query().Get("saved") == "1" { data.Success = "Profile saved." } p.render(w, p.profileTmpl, data) } func (p *Pages) handleProfileUpdate(w http.ResponseWriter, r *http.Request) { profile := &Profile{ Name: r.FormValue("name"), Bio: r.FormValue("bio"), Location: r.FormValue("location"), Website: r.FormValue("website"), PictureURL: r.FormValue("picture_url"), } err := p.Identity.UpdateProfile(r.Context(), profile) if errors.Is(err, ErrNotLoggedIn) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if err != nil { slog.Error("saving profile", "err", err) p.render(w, p.profileTmpl, profileData{ Title: "Profile", Error: "Something went wrong.", Name: profile.Name, Bio: profile.Bio, Location: profile.Location, Website: profile.Website, PictureURL: profile.PictureURL, }) return } http.Redirect(w, r, "/profile?saved=1", http.StatusSeeOther) } func (p *Pages) handleVerify(w http.ResponseWriter, r *http.Request) { combined := r.URL.Query().Get("token") if combined == "" { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Invalid verification link.", OAuthLinks: p.OAuthLinks}) return } err := p.Identity.Verify(r.Context(), combined) if err != nil { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Invalid or expired verification link.", OAuthLinks: p.OAuthLinks}) return } http.Redirect(w, r, "/login?verified=1", http.StatusSeeOther) } func (p *Pages) handleResendVerification(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") if email == "" { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Email is required.", OAuthLinks: p.OAuthLinks}) return } err := p.Identity.ResendVerification(r.Context(), email) if err != nil { slog.Error("resending verification email", "err", err) } p.render(w, p.loginTmpl, loginData{Title: "Login", Success: "If that email is registered, a new verification link has been sent.", OAuthLinks: p.OAuthLinks}) } func (p *Pages) handleForgotPasswordPage(w http.ResponseWriter, r *http.Request) { p.render(w, p.forgotPasswordTmpl, forgotPasswordData{Title: "Forgot Password"}) } func (p *Pages) handleForgotPassword(w http.ResponseWriter, r *http.Request) { email := r.FormValue("email") if email == "" { p.render(w, p.forgotPasswordTmpl, forgotPasswordData{Title: "Forgot Password", Error: "Email is required."}) return } err := p.Identity.RequestPasswordReset(r.Context(), email) if err != nil { slog.Error("requesting password reset", "err", err) } p.render(w, p.forgotPasswordTmpl, forgotPasswordData{ Title: "Forgot Password", Success: "If that email is registered, a password reset link has been sent.", }) } func (p *Pages) handleResetPasswordPage(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { p.render(w, p.loginTmpl, loginData{Title: "Login", Error: "Invalid reset link.", OAuthLinks: p.OAuthLinks}) return } p.render(w, p.resetPasswordTmpl, resetPasswordData{Title: "Reset Password", Token: token}) } func (p *Pages) handleResetPassword(w http.ResponseWriter, r *http.Request) { token := r.FormValue("token") password := r.FormValue("password") if token == "" || password == "" { p.render(w, p.resetPasswordTmpl, resetPasswordData{Title: "Reset Password", Error: "Password is required.", Token: token}) return } err := p.Identity.ResetPassword(r.Context(), token, password) if err != nil { p.render(w, p.resetPasswordTmpl, resetPasswordData{Title: "Reset Password", Error: "Invalid or expired reset link.", Token: token}) return } http.Redirect(w, r, "/login?reset=1", http.StatusSeeOther) } func (p *Pages) handleLogout(w http.ResponseWriter, r *http.Request) { p.Identity.Logout(r.Context()) http.Redirect(w, r, "/login", http.StatusSeeOther) }