Compare commits
9 Commits
0b29602cb8
...
4a5bfc3f10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a5bfc3f10 | ||
|
|
780f5dfb84 | ||
|
|
b6a434ad2a | ||
|
|
71e32f1fd7 | ||
|
|
3c4710bb48 | ||
|
|
86913faed7 | ||
|
|
a9cdc25adf | ||
|
|
6fb4664478 | ||
|
|
a88d807e40 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
/flake.lock
|
||||
/go.sum
|
||||
/.env
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package main
|
||||
|
||||
import "github.com/haydenhargreaves/Potion/internal/app/server"
|
||||
|
||||
const PORT = 3000
|
||||
|
||||
func main() {
|
||||
s := server.Init(PORT).ConfigureAuth().ConnectDatabase().Setup()
|
||||
defer s.DB.Close()
|
||||
|
||||
s.Start()
|
||||
}
|
||||
@ -213,61 +213,6 @@ creation process will take place here
|
||||
|
||||
|
||||
|
||||
|
||||
## Authentication
|
||||
|
||||
This section outlines the authentication requirements for the application. This section
|
||||
is **not** broken down by page, they are simple defined.
|
||||
|
||||
Consider looking into using Google OAuth. Appears to be free and can be implemented pretty easily.
|
||||
Reference [here](https://developers.google.com/identity/protocols/oauth2).
|
||||
|
||||
#### Pages
|
||||
|
||||
- [ ] **Register Page**
|
||||
- [ ] Input form with required details: *name, email and password*
|
||||
- [ ] *Password strength meter**
|
||||
- [ ] User should be directed to the home page when account is created
|
||||
- [ ] User will be logged in
|
||||
- [ ] A notification will be provided to the user indicating success and redirection
|
||||
- [ ] User should see error messages when validation issues occur
|
||||
- [ ] Email already in use
|
||||
- [ ] Passwords do not match
|
||||
- [ ] Server failure (this should never happen)
|
||||
|
||||
- [ ] **Sign In Page**
|
||||
- [ ] Input form with required details: *email, password and forgot password button*
|
||||
- [ ] User should be directed to the home page after signing in
|
||||
- [ ] A notification will be provided to the user indicating success and redirection
|
||||
- [ ] User should see error messages when validation issues occur
|
||||
- [ ] Invalid password
|
||||
- [ ] Server failure (this should never happen)
|
||||
|
||||
'*': Future ideas
|
||||
|
||||
|
||||
#### API Requirements
|
||||
|
||||
- [ ] **Register Page**
|
||||
- [ ] Create a new user in the database
|
||||
- [ ] Password should be stored in the database as a hash
|
||||
- [ ] Ensure that emails are not duplicated
|
||||
- [ ] "Log user in" when account is created
|
||||
- [ ] User should be redirected to the home page on success
|
||||
|
||||
- [ ] **Sign In Page**
|
||||
- [ ] Sign user in and return data to be stored in the session
|
||||
- [ ] Validate password to the hash stored in the DB
|
||||
- [ ] User should be redirected to the home page on success
|
||||
|
||||
- [ ] **Session Management**
|
||||
- [ ] *Uses cookies to store required data**
|
||||
|
||||
'*': Unsure on technical implementation
|
||||
|
||||
|
||||
|
||||
|
||||
## Database Requirements
|
||||
|
||||
This section outlines the specific technical requirements for the database store for
|
||||
@ -277,7 +222,7 @@ this application. It will describe the required tables, fields, and other expect
|
||||
##### Table ID Choice
|
||||
|
||||
Typically, I like to use UUID's for ID's. However, after some research I have concluded that for this
|
||||
application, the use of the `SERIAL` or `BIGSERIAL` type will work sufficiently. Security is not a huge
|
||||
application, the use of the `SERIAL` or `SERIAL` type will work sufficiently. Security is not a huge
|
||||
concern with this application since no major data will be stored, however, measures will still be in
|
||||
place (of course).
|
||||
|
||||
@ -294,8 +239,8 @@ also have a list of attributes which are to be implemented at the database level
|
||||
data fields will also have a small example object. A more in-depth data structure can be
|
||||
found in **OTHER** section.
|
||||
|
||||
- [ ] Recipe: Represents a single recipe.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] Recipes: Represents a single recipe.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] Title (Unique, Required) string(128)
|
||||
- [ ] Description (Required) text
|
||||
- [ ] Instructions (Required) string(1024)[]
|
||||
@ -304,69 +249,78 @@ found in **OTHER** section.
|
||||
- [ ] Duration (Required) JSONB({ "total": int, "prep": int, "cook": int })
|
||||
- [ ] Category (Required) E_Meal (defined in the [enums](#enums-and-types) section)
|
||||
- [ ] Ingredients (Required) JSONB({ "item_a": [{ "name": string, "quantity": string }], "item_b": ... })
|
||||
- [ ] UserId (FK: User.Id) BigSerial
|
||||
- [ ] UserId (FK: User.Id) Serial
|
||||
- [ ] Modified () date/time stamp
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] User: Represents a single user.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] Users: Represents a single user.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] GoogleId (Unique, Required) text
|
||||
- [ ] Name (Required) string(64)
|
||||
- [ ] Email (Unique, Required) string(128)
|
||||
- [ ] Password (Required) string(128) *stored as hash***
|
||||
- [ ] ImageURL () text
|
||||
- [ ] GoogleRefreshToken () text
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Engagement: Represents a single engagement from a single user.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] Sessions: Represents a single user-session.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] UserId (FK: User.Id, Required) Serial
|
||||
- [ ] Token (Required) text
|
||||
- [ ] Expiration (Required) date/time stamp
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Engagements: Represents a single engagement from a single user.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] Message () text (Used to store any relevant notes, if needed)
|
||||
- [ ] Entity (BigSerial) BigSerial (Used to relate an entity, if needed)
|
||||
- [ ] UserId (FK: User.Id, Required) BigSerial
|
||||
- [ ] Entity (Serial) Serial (Used to relate an entity, if needed)
|
||||
- [ ] UserId (FK: User.Id, Required) Serial
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Like: **Many-to-many** table to represent a list of recipes liked by a user.
|
||||
- [ ] Likes: **Many-to-many** table to represent a list of recipes liked by a user.
|
||||
- [ ] ID (PK) *Composite key***
|
||||
- [ ] UserId (FK: User.Id, Required) BigSerial
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
|
||||
- [ ] UserId (FK: User.Id, Required) Serial
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Tag: Represents a single tag that can be had by many recipes.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] Tags: Represents a single tag that can be had by many recipes.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] Name (Unique, Required) string(32)
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] RecipeTag: **Many-to-many** table to represent a list of tags on a recipe.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
|
||||
- [ ] TagId (FK: Tag.Id, Required) BigSerial
|
||||
- [ ] RecipeTags: **Many-to-many** table to represent a list of tags on a recipe.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
|
||||
- [ ] TagId (FK: Tag.Id, Required) Serial
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] List: Represents a single users shopping list.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] UserId (FK: User.Id, Required) BigSerial
|
||||
- [ ] Lists: Represents a single users shopping list.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] UserId (FK: User.Id, Required) Serial
|
||||
- [ ] Content (Required) JSONB([ { "name": string, "quantity": string }, ... ])
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Image: Represents a single image used by a single recipe.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
|
||||
- [ ] Images: Represents a single image used by a single recipe.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
|
||||
- [ ] Alt (Required) string(128) (alt text for accessibility, same as recipe title)
|
||||
- [ ] Url (Required) text
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Review: Represents a single review on a recipe from a single author.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] Reviews: Represents a single review on a recipe from a single author.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] Comment (Required) text
|
||||
- [ ] Rating () int(0..5) (Optional b/c nested replies don't need a rating)
|
||||
- [ ] ReviewId (FK: Review.Id) BigSerial (This is used for nested replies)
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) BigSerial
|
||||
- [ ] UserId (FK: User.Id, Required) BigSerial
|
||||
- [ ] ReviewId (FK: Review.Id) Serial (This is used for nested replies)
|
||||
- [ ] RecipeId (FK: Recipe.Id, Required) Serial
|
||||
- [ ] UserId (FK: User.Id, Required) Serial
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
- [ ] Notification: Represents a single user notification.
|
||||
- [ ] ID (PK) BigSerial
|
||||
- [ ] UserId (FK: User.Id, Required) BigSerial
|
||||
- [ ] Notifications: Represents a single user notification.
|
||||
- [ ] ID (PK) Serial
|
||||
- [ ] UserId (FK: User.Id, Required) Serial
|
||||
- [ ] Type (Required) E_Notification
|
||||
- [ ] Message (Required) text
|
||||
- [ ] Entity (BigSerial) BigSerial (Used to relate an entity, if needed)
|
||||
- [ ] Entity (Serial) Serial (Used to relate an entity, if needed)
|
||||
- [ ] Read (Required, Default: F) boolean
|
||||
- [ ] Created (Required) date/time stamp
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
templ
|
||||
tailwindcss_4
|
||||
tailwindcss-language-server
|
||||
watchman
|
||||
];
|
||||
|
||||
# Define the shell that will be executed.
|
||||
|
||||
44
go.mod
44
go.mod
@ -2,4 +2,46 @@ module github.com/haydenhargreaves/Potion
|
||||
|
||||
go 1.24.3
|
||||
|
||||
require github.com/a-h/templ v0.3.898 // indirect
|
||||
require (
|
||||
github.com/a-h/templ v0.3.898
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/a-h/templ/examples/integration-gin v0.0.0-20250610141150-9b34663a6ef0 // indirect
|
||||
github.com/bytedance/sonic v1.13.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/cors v1.7.5 // indirect
|
||||
github.com/gin-contrib/sessions v1.0.4 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.16.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/net v0.39.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
30
internal/app/handlers/auth_handler.go
Normal file
30
internal/app/handlers/auth_handler.go
Normal file
@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
)
|
||||
|
||||
func GoogleLogin(ctx *gin.Context) {
|
||||
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
|
||||
url := deps.AuthService.GetGoogleAuthUrl()
|
||||
|
||||
ctx.Redirect(http.StatusSeeOther, url)
|
||||
}
|
||||
|
||||
func GoogleCallback(ctx *gin.Context) {
|
||||
deps := ctx.MustGet("deps").(*domain.InjectedDependencies)
|
||||
|
||||
var (
|
||||
state string = ctx.Query("state")
|
||||
code string = ctx.Query("code")
|
||||
)
|
||||
|
||||
if dbUser, googleUserInfo, err := deps.AuthService.GoogleAuthSuccess(state, code); err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
} else {
|
||||
ctx.JSON(http.StatusOK, gin.H{"googleUserInfo": googleUserInfo, "dbUser": dbUser})
|
||||
}
|
||||
}
|
||||
14
internal/app/handlers/page_handler.go
Normal file
14
internal/app/handlers/page_handler.go
Normal file
@ -0,0 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
layouts "github.com/haydenhargreaves/Potion/internal/templates/layouts"
|
||||
pages "github.com/haydenhargreaves/Potion/internal/templates/pages"
|
||||
)
|
||||
|
||||
func LoginPage(ctx *gin.Context) {
|
||||
title := "Potion - Login"
|
||||
page := pages.LoginPage()
|
||||
|
||||
ctx.HTML(200, "", layouts.AppLayout(title, page))
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
package handlers
|
||||
15
internal/app/server/middleware.go
Normal file
15
internal/app/server/middleware.go
Normal file
@ -0,0 +1,15 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
)
|
||||
|
||||
// DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware
|
||||
// that is used to apply the required services.
|
||||
func DepedencyInjectionMiddleware(deps *domain.InjectedDependencies) gin.HandlerFunc {
|
||||
return func(ctx *gin.Context) {
|
||||
ctx.Set("deps", deps)
|
||||
ctx.Next()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/a-h/templ/examples/integration-gin/gintemplrenderer"
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/haydenhargreaves/Potion/internal/app/handlers"
|
||||
"github.com/haydenhargreaves/Potion/internal/app/service"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/server"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository"
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
port int
|
||||
Router *gin.Engine
|
||||
config cors.Config
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
// Init initializes the server with the provided port. CORS settings are defined here.
|
||||
// A pointer to a server object is returned which allows for method chaining.
|
||||
func Init(port int) *Server {
|
||||
// TODO: Set this to release in prod
|
||||
gin.SetMode(gin.DebugMode)
|
||||
|
||||
server := &Server{
|
||||
Router: gin.Default(),
|
||||
port: port,
|
||||
config: cors.DefaultConfig(),
|
||||
}
|
||||
|
||||
// Some stuff for templ rendering
|
||||
htmlRenderer := server.Router.HTMLRender
|
||||
server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer}
|
||||
|
||||
// Disable proxy warnings
|
||||
server.Router.SetTrustedProxies(nil)
|
||||
|
||||
// Setup the CORS settings and active them
|
||||
server.config.AllowAllOrigins = true
|
||||
server.Router.Use(cors.New(server.config))
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
func (s *Server) ConfigureAuth() *Server {
|
||||
err := godotenv.Load(".env")
|
||||
if err != nil {
|
||||
panic("Could not load env file")
|
||||
}
|
||||
|
||||
var (
|
||||
redirectUrl string = "http://localhost:3000/v1/api/auth/callback"
|
||||
clientId string = os.Getenv("GOOGLE_CLIENT_ID")
|
||||
clientSecret string = os.Getenv("GOOGLE_CLIENT_SECRET")
|
||||
scope []string = []string{
|
||||
"https://www.googleapis.com/auth/userinfo.email",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
}
|
||||
)
|
||||
|
||||
// Setup Google OAuth
|
||||
auth.NewGoogleConfig(redirectUrl, clientId, clientSecret, scope)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ConnectDatabase() *Server {
|
||||
err := godotenv.Load(".env")
|
||||
if err != nil {
|
||||
panic("Could not load env file")
|
||||
}
|
||||
|
||||
var connUrl string = os.Getenv("DATABASE_URL")
|
||||
|
||||
db, err := sql.Open("postgres", connUrl)
|
||||
if err != nil {
|
||||
panic("Could not connect to database: " + err.Error())
|
||||
}
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
panic("Error pinging database: " + err.Error())
|
||||
}
|
||||
|
||||
s.DB = db
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Start starts the server on the port provided when the server was initialized
|
||||
func (s *Server) Start() {
|
||||
s.Router.Run(fmt.Sprintf(":%d", s.port))
|
||||
}
|
||||
|
||||
func (s *Server) Setup() *Server {
|
||||
// Initialize and inject dependencies
|
||||
userRepo := repository.NewUserRepository(s.DB)
|
||||
userService := service.NewUserService(userRepo)
|
||||
authService := service.NewAuthService(userRepo)
|
||||
|
||||
deps := &domain.InjectedDependencies{
|
||||
UserService: userService,
|
||||
AuthService: authService,
|
||||
}
|
||||
|
||||
s.Router.Use(DepedencyInjectionMiddleware(deps))
|
||||
|
||||
// Wrap all routes with a version
|
||||
router_v1 := s.Router.Group("/v1")
|
||||
|
||||
// Domain specific routers
|
||||
router_web := router_v1.Group("/web")
|
||||
router_api := router_v1.Group("/api")
|
||||
|
||||
// Static routes
|
||||
router_web.Static("/static", "./web/static")
|
||||
|
||||
// API router endpoints
|
||||
router_api.GET("/", func(ctx *gin.Context) { ctx.JSON(200, gin.H{"message": "Server is active."}) })
|
||||
|
||||
// WEB router endpoints
|
||||
router_web.GET("/login", handlers.LoginPage)
|
||||
|
||||
// Google oauth
|
||||
router_api.GET("/auth/login", handlers.GoogleLogin)
|
||||
router_api.GET("/auth/callback", handlers.GoogleCallback)
|
||||
|
||||
return s
|
||||
}
|
||||
94
internal/app/service/auth_service.go
Normal file
94
internal/app/service/auth_service.go
Normal file
@ -0,0 +1,94 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
"github.com/haydenhargreaves/Potion/internal/infrastructure/auth"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// NOTE: HOW THIS WORKS
|
||||
//
|
||||
// I need to store the refresh token along side the user in the DB.
|
||||
// Create a "session token" (cookie) with an expiration and store that in the DB and the session.
|
||||
// Send this session token along with all the requests (stored in the session) and when
|
||||
// authorization is needed, use it.
|
||||
// Once the expiration of MY token expires, prompt the user to log back in.
|
||||
//
|
||||
// So what is the point of the Google refresh token? Well, its not really useful right now, but if
|
||||
// we need to perform Google actions, we can use it to get more access tokens, which are needed for
|
||||
// the Google actions. Currently, I have no need for it, but it will be stored anyway.
|
||||
//
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
// AuthService implements the domain.AuthService defined in the domain module.
|
||||
type AuthService struct {
|
||||
userRepository domain.UserRepository
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the AuthService implements domain.AuthService
|
||||
var _ domainAuth.AuthService = (*AuthService)(nil)
|
||||
|
||||
// NewAuthService creates a user service object which can be passed into the context. The service
|
||||
// requires a user repository which it will use to hit the database when needed.
|
||||
func NewAuthService(userRepository domain.UserRepository) domainAuth.AuthService {
|
||||
return &AuthService{userRepository: userRepository}
|
||||
}
|
||||
|
||||
// GetGoogleAuthUrl generates a URL which is used to redirect the user to the Google sign in page.
|
||||
// This URL is in the Google domain and is not coupled with this application. The data from this URL
|
||||
// will be passed into a callback and work to complete the Google OAuth workflow.
|
||||
func (s *AuthService) GetGoogleAuthUrl() string {
|
||||
url := auth.GoogleAuthConfig.AuthCodeURL(
|
||||
"randomstate",
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.ApprovalForce,
|
||||
)
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
// GoogleAuthSuccess accepts the data from the Google login endpoint and uses it to fetch the users
|
||||
// data. The data is then used to log the user in or create an account.
|
||||
func (s *AuthService) GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error) {
|
||||
// Ensure the state matches, prevents M.I.T.M. attacks
|
||||
if state != "randomstate" {
|
||||
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("States don't match, received %s", state)
|
||||
}
|
||||
|
||||
// Get access token from Google
|
||||
token, err := auth.GoogleAuthConfig.Exchange(context.Background(), code)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Code exchange failed: %s", err.Error())
|
||||
}
|
||||
|
||||
// Use the access token to get user data
|
||||
googleUserInfo, err := auth.GetUserData(token.AccessToken)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.GoogleUserInfo{}, err
|
||||
}
|
||||
|
||||
// Attempt to get the user, user is nil when they don't exit
|
||||
user, err := s.userRepository.GetGoogleUser(googleUserInfo.Id)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Failed to get db user: %s", err)
|
||||
}
|
||||
|
||||
// A user was found
|
||||
if user != nil {
|
||||
return *user, googleUserInfo, nil
|
||||
}
|
||||
|
||||
// user did not exist, need to create one
|
||||
newUser, err := s.userRepository.CreateGoogleUser(&googleUserInfo, token.RefreshToken)
|
||||
if err != nil {
|
||||
return domain.User{}, domain.GoogleUserInfo{}, fmt.Errorf("Repository failed to create user: %s", err.Error())
|
||||
}
|
||||
|
||||
// temporary
|
||||
return newUser, googleUserInfo, nil
|
||||
|
||||
}
|
||||
17
internal/app/service/user_service.go
Normal file
17
internal/app/service/user_service.go
Normal file
@ -0,0 +1,17 @@
|
||||
package service
|
||||
|
||||
import domain "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
|
||||
// UserService implements the domain.UserService defined in the domain module.
|
||||
type UserService struct {
|
||||
userRepository domain.UserRepository
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the UserService implements domain.UserService
|
||||
var _ domain.UserService = (*UserService)(nil)
|
||||
|
||||
// NewUserService creates a user service object which can be passed into the context. The service
|
||||
// requires a user repository which it will use to hit the database when needed.
|
||||
func NewUserService(userRepository domain.UserRepository) domain.UserService {
|
||||
return &UserService{userRepository: userRepository}
|
||||
}
|
||||
10
internal/domain/auth/service.go
Normal file
10
internal/domain/auth/service.go
Normal file
@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
)
|
||||
|
||||
type AuthService interface {
|
||||
GetGoogleAuthUrl() string
|
||||
GoogleAuthSuccess(state, code string) (domain.User, domain.GoogleUserInfo, error)
|
||||
}
|
||||
11
internal/domain/server/server.go
Normal file
11
internal/domain/server/server.go
Normal file
@ -0,0 +1,11 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
domainAuth "github.com/haydenhargreaves/Potion/internal/domain/auth"
|
||||
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
)
|
||||
|
||||
type InjectedDependencies struct {
|
||||
UserService domainUser.UserService
|
||||
AuthService domainAuth.AuthService
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package domain
|
||||
|
||||
type UserRepository interface {
|
||||
CreateGoogleUser(googleUserInfo *GoogleUserInfo, googleRefreshToken string) (User, error)
|
||||
GetGoogleUser(googleId string) (*User, error)
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package domain
|
||||
|
||||
type UserService interface {
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// GoogleUserInfo is a data type which contains a mapping of the Google User Info API call.
|
||||
type GoogleUserInfo struct {
|
||||
Id string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified_email"`
|
||||
Name string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// User is the database model of a user. There is no need to map to a different model so
|
||||
// this will remain in the domain.
|
||||
type User struct {
|
||||
Id int
|
||||
GoogleId string
|
||||
Name string
|
||||
Email string
|
||||
ImageUrl string
|
||||
GoogleRefreshToken string
|
||||
Created time.Time
|
||||
}
|
||||
61
internal/infrastructure/auth/google.go
Normal file
61
internal/infrastructure/auth/google.go
Normal file
@ -0,0 +1,61 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
)
|
||||
|
||||
// GoogleAuthConfig is a global variable that stores the current state of the Google configuration.
|
||||
// NewGoogleConfig should be called before this value is accessed, or the application will panic
|
||||
// to prevent a null reference.
|
||||
var GoogleAuthConfig oauth2.Config
|
||||
|
||||
// NewGOogleConfig creates a google authentication configuration. The configuration is set as a
|
||||
// global variable which can be used by importing this module. The configuration is used to query
|
||||
// the google OAuth API and collect user data.
|
||||
//
|
||||
// This function should be called before any calls to the Google API are made, otherwise a null
|
||||
// value will be accessed and the call will panic.
|
||||
//
|
||||
// The global variable is returned by this function but is not needed.
|
||||
func NewGoogleConfig(redirectUrl, clientId, clientSecret string, scope []string) oauth2.Config {
|
||||
GoogleAuthConfig = oauth2.Config{
|
||||
RedirectURL: redirectUrl,
|
||||
ClientID: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
Scopes: scope,
|
||||
Endpoint: google.Endpoint,
|
||||
}
|
||||
|
||||
return GoogleAuthConfig
|
||||
}
|
||||
|
||||
// GetUserData accepts a access token and calls the Google OAuth API to receive the respective user
|
||||
// data. Any errors that occur will be wrapped and returned to the caller.
|
||||
func GetUserData(accessToken string) (domain.GoogleUserInfo, error) {
|
||||
googleApiUrl := fmt.Sprintf("https://www.googleapis.com/oauth2/v2/userinfo?access_token=%s", accessToken)
|
||||
|
||||
resp, err := http.Get(googleApiUrl)
|
||||
if err != nil {
|
||||
return domain.GoogleUserInfo{}, fmt.Errorf("Failed to fetch user info from Google: %s", err.Error())
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return domain.GoogleUserInfo{}, fmt.Errorf("Failed to read body from response: %s", err.Error())
|
||||
}
|
||||
|
||||
var googleUserInfo domain.GoogleUserInfo
|
||||
if err := json.Unmarshal(body, &googleUserInfo); err != nil {
|
||||
return domain.GoogleUserInfo{}, fmt.Errorf("Failed to parser response: %s", err.Error())
|
||||
}
|
||||
|
||||
return googleUserInfo, nil
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com)
|
||||
-- Desc: Create the users table in the database.
|
||||
-- Date: 06/13/2025
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Create the users table
|
||||
CREATE TABLE IF NOT EXISTS Users (
|
||||
Id SERIAL PRIMARY KEY NOT NULL,
|
||||
GoogleId TEXT UNIQUE NOT NULL,
|
||||
Name VARCHAR(64) NOT NULL,
|
||||
Email VARCHAR(128) UNIQUE NOT NULL,
|
||||
ImageUrl TEXT,
|
||||
GoogleRefreshToken TEXT,
|
||||
Created TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
107
internal/infrastructure/database/repository/user_repository.go
Normal file
107
internal/infrastructure/database/repository/user_repository.go
Normal file
@ -0,0 +1,107 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// Compile-time check to ensure the UserRepository implements domain.UserRepository
|
||||
var _ domain.UserRepository = (*UserRepository)(nil)
|
||||
|
||||
// NewUserRepository creates a user repository object which is used by the user service to access
|
||||
// the database. Any user related database operations will take place in this repository.
|
||||
func NewUserRepository(db *sql.DB) domain.UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
// CreateGoogleUser creates a user entry in the users table. The refresh token is required along
|
||||
// with the Google user info, in order to complete the database schema. Currently, Google login is
|
||||
// the only support method of authentication, if this changes, this repository may need updates to
|
||||
// match an updated table schema.
|
||||
//
|
||||
// This function will NOT check if the user already exists, if they do, it will return an error. For
|
||||
// best results, pair this function with the GetGoogleUser which will return the user if it can find
|
||||
// it.
|
||||
func (r *UserRepository) CreateGoogleUser(googleUserInfo *domain.GoogleUserInfo, googleRefreshToken string) (domain.User, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
query := `INSERT INTO users
|
||||
(GoogleId, Name, Email, ImageUrl, GoogleRefreshToken)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *;`
|
||||
|
||||
if err := tx.QueryRow(
|
||||
query,
|
||||
googleUserInfo.Id,
|
||||
googleUserInfo.Name,
|
||||
googleUserInfo.Email,
|
||||
googleUserInfo.Picture,
|
||||
googleRefreshToken,
|
||||
).Scan(
|
||||
&user.Id,
|
||||
&user.GoogleId,
|
||||
&user.Name,
|
||||
&user.Email,
|
||||
&user.ImageUrl,
|
||||
&user.GoogleRefreshToken,
|
||||
&user.Created,
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return domain.User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetGoogleUser attempts to find a user in the database via its Google ID, not the database ID. This
|
||||
// function is used when a user logs in with Google to prevent duplicate entries from being made. If
|
||||
// no user is found, this function will return a null pointer but not an error.
|
||||
func (r *UserRepository) GetGoogleUser(googleId string) (*domain.User, error) {
|
||||
tx, err := r.db.Begin()
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user domain.User
|
||||
query := `SELECT * FROM users WHERE GoogleId = $1`
|
||||
|
||||
if err := tx.QueryRow(query, googleId).Scan(
|
||||
&user.Id,
|
||||
&user.GoogleId,
|
||||
&user.Name,
|
||||
&user.Email,
|
||||
&user.ImageUrl,
|
||||
&user.GoogleRefreshToken,
|
||||
&user.Created,
|
||||
); err != nil {
|
||||
// If no user was found, don't error, just return
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package templates
|
||||
|
||||
// AppLayout is the main application layout, this does not contain any content other than
|
||||
// meta data, links, scripts and whatever is passed into it as a component.
|
||||
templ AppLayout(title string, child templ.Component) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{ title }</title>
|
||||
<link rel="stylesheet" href="/v1/web/static/css/tailwind.css" />
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
<body>
|
||||
@child
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@ -1,10 +1,63 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.865
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
// AppLayout is the main application layout, this does not contain any content other than
|
||||
// meta data, links, scripts and whatever is passed into it as a component.
|
||||
func AppLayout(title string, child templ.Component) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/layouts/app_layout.templ`, Line: 12, Col: 16}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"/v1/web/static/css/tailwind.css\"><script src=\"https://unpkg.com/htmx.org@2.0.4\"></script></head><body>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = child.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.865
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
45
internal/templates/pages/login.templ
Normal file
45
internal/templates/pages/login.templ
Normal file
@ -0,0 +1,45 @@
|
||||
package templates
|
||||
|
||||
templ LoginPage() {
|
||||
<div class="h-screen w-full grid place-items-center bg-gray-100">
|
||||
<div class="w-3/4 sm:w-3/4 md:w-1/2 lg:w-2/7 bg-white border border-gray-200 rounded-xl shadow-2xs">
|
||||
<div class="p-4 sm:p-7">
|
||||
<div class="">
|
||||
<h1 class="block text-2xl font-bold text-gray-800">
|
||||
Sign in to Continue
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
You need to sign in to continue. Don't have an account? Signing in will
|
||||
create one for you!
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<a
|
||||
href="/v1/api/auth/login"
|
||||
class="w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<svg class="w-4 h-auto" width="46" height="47" viewBox="0 0 46 47" fill="none">
|
||||
<path
|
||||
d="M46 24.0287C46 22.09 45.8533 20.68 45.5013 19.2112H23.4694V27.9356H36.4069C36.1429 30.1094 34.7347 33.37 31.5957 35.5731L31.5663 35.8669L38.5191 41.2719L38.9885 41.3306C43.4477 37.2181 46 31.1669 46 24.0287Z"
|
||||
fill="#4285F4"
|
||||
></path>
|
||||
<path
|
||||
d="M23.4694 47C29.8061 47 35.1161 44.9144 39.0179 41.3012L31.625 35.5437C29.6301 36.9244 26.9898 37.8937 23.4987 37.8937C17.2793 37.8937 12.0281 33.7812 10.1505 28.1412L9.88649 28.1706L2.61097 33.7812L2.52296 34.0456C6.36608 41.7125 14.287 47 23.4694 47Z"
|
||||
fill="#34A853"
|
||||
></path>
|
||||
<path
|
||||
d="M10.1212 28.1413C9.62245 26.6725 9.32908 25.1156 9.32908 23.5C9.32908 21.8844 9.62245 20.3275 10.0918 18.8588V18.5356L2.75765 12.8369L2.52296 12.9544C0.909439 16.1269 0 19.7106 0 23.5C0 27.2894 0.909439 30.8731 2.49362 34.0456L10.1212 28.1413Z"
|
||||
fill="#FBBC05"
|
||||
></path>
|
||||
<path
|
||||
d="M23.4694 9.07688C27.8699 9.07688 30.8622 10.9863 32.5344 12.5725L39.1645 6.11C35.0867 2.32063 29.8061 0 23.4694 0C14.287 0 6.36607 5.2875 2.49362 12.9544L10.0918 18.8588C11.9987 13.1894 17.25 9.07688 23.4694 9.07688Z"
|
||||
fill="#EB4335"
|
||||
></path>
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
40
internal/templates/pages/login_templ.go
Normal file
40
internal/templates/pages/login_templ.go
Normal file
@ -0,0 +1,40 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.865
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func LoginPage() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"h-screen w-full grid place-items-center bg-gray-100\"><div class=\"w-3/4 sm:w-3/4 md:w-1/2 lg:w-2/7 bg-white border border-gray-200 rounded-xl shadow-2xs\"><div class=\"p-4 sm:p-7\"><div class=\"\"><h1 class=\"block text-2xl font-bold text-gray-800\">Sign in to Continue</h1><p class=\"mt-2 text-sm text-gray-600\">You need to sign in to continue. Don't have an account? Signing in will create one for you!</p></div><div class=\"mt-5\"><a href=\"/v1/api/auth/login\" class=\"w-full py-3 px-4 inline-flex justify-center items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-800 shadow-2xs hover:bg-gray-50 focus:outline-hidden focus:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none\"><svg class=\"w-4 h-auto\" width=\"46\" height=\"47\" viewBox=\"0 0 46 47\" fill=\"none\"><path d=\"M46 24.0287C46 22.09 45.8533 20.68 45.5013 19.2112H23.4694V27.9356H36.4069C36.1429 30.1094 34.7347 33.37 31.5957 35.5731L31.5663 35.8669L38.5191 41.2719L38.9885 41.3306C43.4477 37.2181 46 31.1669 46 24.0287Z\" fill=\"#4285F4\"></path> <path d=\"M23.4694 47C29.8061 47 35.1161 44.9144 39.0179 41.3012L31.625 35.5437C29.6301 36.9244 26.9898 37.8937 23.4987 37.8937C17.2793 37.8937 12.0281 33.7812 10.1505 28.1412L9.88649 28.1706L2.61097 33.7812L2.52296 34.0456C6.36608 41.7125 14.287 47 23.4694 47Z\" fill=\"#34A853\"></path> <path d=\"M10.1212 28.1413C9.62245 26.6725 9.32908 25.1156 9.32908 23.5C9.32908 21.8844 9.62245 20.3275 10.0918 18.8588V18.5356L2.75765 12.8369L2.52296 12.9544C0.909439 16.1269 0 19.7106 0 23.5C0 27.2894 0.909439 30.8731 2.49362 34.0456L10.1212 28.1413Z\" fill=\"#FBBC05\"></path> <path d=\"M23.4694 9.07688C27.8699 9.07688 30.8622 10.9863 32.5344 12.5725L39.1645 6.11C35.0867 2.32063 29.8061 0 23.4694 0C14.287 0 6.36607 5.2875 2.49362 12.9544L10.0918 18.8588C11.9987 13.1894 17.25 9.07688 23.4694 9.07688Z\" fill=\"#EB4335\"></path></svg> Sign in with Google</a></div></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@ -1,5 +1,8 @@
|
||||
module.exports = {
|
||||
content: ["./**/*.html", "./**/*.templ", "./**/*.go",],
|
||||
content: [
|
||||
"./internal/templates/**/*.templ",
|
||||
"./internal/templates/**/*.html",
|
||||
],
|
||||
theme: { extend: {}, },
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
3
web/static/css/main.css
Normal file
3
web/static/css/main.css
Normal file
@ -0,0 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@source "./internal/templates/**/*.templ";
|
||||
@plugin "daisyui";
|
||||
1311
web/static/css/tailwind.css
Normal file
1311
web/static/css/tailwind.css
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user