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)
}