forked from mirrors/homebox
Chore/cleanup/misc fixes
This commit is contained in:
commit
888ecfde34
38 changed files with 692 additions and 408 deletions
13
.github/workflows/frontend.yaml
vendored
13
.github/workflows/frontend.yaml
vendored
|
@ -24,6 +24,19 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: 6.0.2
|
version: 6.0.2
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: 1.18
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 18
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v2.2.2
|
||||||
|
with:
|
||||||
|
version: 6.0.2
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
|
|
1
.github/workflows/go.yaml
vendored
1
.github/workflows/go.yaml
vendored
|
@ -27,7 +27,6 @@ jobs:
|
||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
# Optional: working directory, useful for monorepos
|
# Optional: working directory, useful for monorepos
|
||||||
timeout: 5m
|
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
|
|
||||||
- name: Build API
|
- name: Build API
|
||||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -1,7 +1,6 @@
|
||||||
# Project Specific
|
# Project Specific
|
||||||
api.log
|
|
||||||
config.yml
|
config.yml
|
||||||
ent.db
|
homebox.db
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,6 @@ RUN pnpm install --frozen-lockfile --shamefully-hoist
|
||||||
COPY frontend .
|
COPY frontend .
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Build API
|
# Build API
|
||||||
FROM golang:alpine AS builder
|
FROM golang:alpine AS builder
|
||||||
RUN apk update
|
RUN apk update
|
||||||
|
@ -20,7 +18,6 @@ COPY --from=frontend-builder app/.output go/src/app/app/api/public
|
||||||
RUN go get -d -v ./...
|
RUN go get -d -v ./...
|
||||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /go/bin/api -v ./app/api/*.go
|
RUN CGO_ENABLED=1 GOOS=linux go build -o /go/bin/api -v ./app/api/*.go
|
||||||
|
|
||||||
|
|
||||||
# Production Stage
|
# Production Stage
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ type app struct {
|
||||||
services *services.AllServices
|
services *services.AllServices
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(conf *config.Config) *app {
|
func new(conf *config.Config) *app {
|
||||||
s := &app{
|
s := &app{
|
||||||
conf: conf,
|
conf: conf,
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,7 @@ func NewApp(conf *config.Config) *app {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) StartBgTask(t time.Duration, fn func()) {
|
func (a *app) startBgTask(t time.Duration, fn func()) {
|
||||||
for {
|
for {
|
||||||
a.server.Background(fn)
|
a.server.Background(fn)
|
||||||
time.Sleep(t)
|
time.Sleep(t)
|
||||||
|
|
41
backend/app/api/logger.go
Normal file
41
backend/app/api/logger.go
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/hay-kot/content/backend/internal/config"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupLogger initializes the zerolog config
|
||||||
|
// for the shared logger.
|
||||||
|
func (a *app) setupLogger() {
|
||||||
|
// Logger Init
|
||||||
|
// zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||||
|
if a.conf.Mode != config.ModeProduction {
|
||||||
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Level(getLevel(a.conf.Log.Level))
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLevel(l string) zerolog.Level {
|
||||||
|
switch strings.ToLower(l) {
|
||||||
|
case "debug":
|
||||||
|
return zerolog.DebugLevel
|
||||||
|
case "info":
|
||||||
|
return zerolog.InfoLevel
|
||||||
|
case "warn":
|
||||||
|
return zerolog.WarnLevel
|
||||||
|
case "error":
|
||||||
|
return zerolog.ErrorLevel
|
||||||
|
case "fatal":
|
||||||
|
return zerolog.FatalLevel
|
||||||
|
case "panic":
|
||||||
|
return zerolog.PanicLevel
|
||||||
|
default:
|
||||||
|
return zerolog.InfoLevel
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,6 @@ package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hay-kot/content/backend/app/api/docs"
|
"github.com/hay-kot/content/backend/app/api/docs"
|
||||||
|
@ -12,7 +11,6 @@ import (
|
||||||
"github.com/hay-kot/content/backend/internal/services"
|
"github.com/hay-kot/content/backend/internal/services"
|
||||||
"github.com/hay-kot/content/backend/pkgs/server"
|
"github.com/hay-kot/content/backend/pkgs/server"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"github.com/rs/zerolog"
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,10 +25,6 @@ import (
|
||||||
// @name Authorization
|
// @name Authorization
|
||||||
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
// @description "Type 'Bearer TOKEN' to correctly set the API Key"
|
||||||
func main() {
|
func main() {
|
||||||
// Logger Init
|
|
||||||
// zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
|
||||||
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
|
|
||||||
log.Level(zerolog.DebugLevel)
|
|
||||||
|
|
||||||
cfgFile := "config.yml"
|
cfgFile := "config.yml"
|
||||||
|
|
||||||
|
@ -47,8 +41,9 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(cfg *config.Config) error {
|
func run(cfg *config.Config) error {
|
||||||
app := NewApp(cfg)
|
app := new(cfg)
|
||||||
|
|
||||||
|
app.setupLogger()
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Initialize Database & Repos
|
// Initialize Database & Repos
|
||||||
|
|
||||||
|
@ -75,22 +70,19 @@ func run(cfg *config.Config) error {
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Start Server
|
// Start Server
|
||||||
|
|
||||||
app.conf.Print()
|
|
||||||
|
|
||||||
app.server = server.NewServer(app.conf.Web.Host, app.conf.Web.Port)
|
app.server = server.NewServer(app.conf.Web.Host, app.conf.Web.Port)
|
||||||
|
|
||||||
routes := app.newRouter(app.repos)
|
routes := app.newRouter(app.repos)
|
||||||
app.LogRoutes(routes)
|
|
||||||
|
|
||||||
app.SeedDatabase(app.repos)
|
if app.conf.Mode != config.ModeDevelopment {
|
||||||
|
app.logRoutes(routes)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info().Msgf("Starting HTTP Server on %s:%s", app.server.Host, app.server.Port)
|
log.Info().Msgf("Starting HTTP Server on %s:%s", app.server.Host, app.server.Port)
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Start Reoccurring Tasks
|
// Start Reoccurring Tasks
|
||||||
|
|
||||||
go app.StartBgTask(time.Duration(24)*time.Hour, func() {
|
go app.startBgTask(time.Duration(24)*time.Hour, func() {
|
||||||
_, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background())
|
_, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
|
|
|
@ -87,8 +87,21 @@ func mwStripTrailingSlash(next http.Handler) http.Handler {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StatusRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
Status int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *StatusRecorder) WriteHeader(status int) {
|
||||||
|
r.Status = status
|
||||||
|
r.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) mwStructLogger(next http.Handler) http.Handler {
|
func (a *app) mwStructLogger(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
record := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK}
|
||||||
|
next.ServeHTTP(record, r)
|
||||||
|
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
|
@ -101,25 +114,35 @@ func (a *app) mwStructLogger(next http.Handler) http.Handler {
|
||||||
Str("url", url).
|
Str("url", url).
|
||||||
Str("method", r.Method).
|
Str("method", r.Method).
|
||||||
Str("remote_addr", r.RemoteAddr).
|
Str("remote_addr", r.RemoteAddr).
|
||||||
Msgf("[%s] %s", r.Method, url)
|
Int("status", record.Status).
|
||||||
next.ServeHTTP(w, r)
|
Msg(url)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) mwSummaryLogger(next http.Handler) http.Handler {
|
func (a *app) mwSummaryLogger(next http.Handler) http.Handler {
|
||||||
bold := func(s string) string {
|
bold := func(s string) string { return "\033[1m" + s + "\033[0m" }
|
||||||
return "\033[1m" + s + "\033[0m"
|
orange := func(s string) string { return "\033[33m" + s + "\033[0m" }
|
||||||
}
|
aqua := func(s string) string { return "\033[36m" + s + "\033[0m" }
|
||||||
|
red := func(s string) string { return "\033[31m" + s + "\033[0m" }
|
||||||
|
green := func(s string) string { return "\033[32m" + s + "\033[0m" }
|
||||||
|
|
||||||
pink := func(s string) string {
|
fmtCode := func(code int) string {
|
||||||
return "\033[35m" + s + "\033[0m"
|
switch {
|
||||||
}
|
case code >= 500:
|
||||||
|
return red(fmt.Sprintf("%d", code))
|
||||||
aqua := func(s string) string {
|
case code >= 400:
|
||||||
return "\033[36m" + s + "\033[0m"
|
return orange(fmt.Sprintf("%d", code))
|
||||||
|
case code >= 300:
|
||||||
|
return aqua(fmt.Sprintf("%d", code))
|
||||||
|
default:
|
||||||
|
return green(fmt.Sprintf("%d", code))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
record := &StatusRecorder{ResponseWriter: w, Status: http.StatusOK}
|
||||||
|
next.ServeHTTP(record, r) // Blocks until the next handler returns.
|
||||||
|
|
||||||
scheme := "http"
|
scheme := "http"
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
|
@ -127,7 +150,11 @@ func (a *app) mwSummaryLogger(next http.Handler) http.Handler {
|
||||||
|
|
||||||
url := fmt.Sprintf("%s://%s%s %s", scheme, r.Host, r.RequestURI, r.Proto)
|
url := fmt.Sprintf("%s://%s%s %s", scheme, r.Host, r.RequestURI, r.Proto)
|
||||||
|
|
||||||
log.Info().Msgf("%s %s", bold(pink("["+r.Method+"]")), aqua(url))
|
log.Info().
|
||||||
next.ServeHTTP(w, r)
|
Msgf("%s %s %s",
|
||||||
|
bold(orange(""+r.Method+"")),
|
||||||
|
aqua(url),
|
||||||
|
bold(fmtCode(record.Status)),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
@ -19,21 +18,22 @@ import (
|
||||||
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
|
httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:public/*
|
|
||||||
var public embed.FS
|
|
||||||
|
|
||||||
const prefix = "/api"
|
const prefix = "/api"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDir = errors.New("path is dir")
|
||||||
|
|
||||||
|
//go:embed all:public/*
|
||||||
|
public embed.FS
|
||||||
|
)
|
||||||
|
|
||||||
// registerRoutes registers all the routes for the API
|
// registerRoutes registers all the routes for the API
|
||||||
func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||||
|
registerMimes()
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
a.setGlobalMiddleware(r)
|
a.setGlobalMiddleware(r)
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Base Routes
|
|
||||||
|
|
||||||
DumpEmbedContents()
|
|
||||||
|
|
||||||
r.Get("/swagger/*", httpSwagger.Handler(
|
r.Get("/swagger/*", httpSwagger.Handler(
|
||||||
httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)),
|
httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)),
|
||||||
))
|
))
|
||||||
|
@ -78,14 +78,13 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
r.NotFound(NotFoundHandler)
|
r.NotFound(notFoundHandler())
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogRoutes logs the routes of the server that are registered within Server.registerRoutes(). This is useful for debugging.
|
// logRoutes logs the routes of the server that are registered within Server.registerRoutes(). This is useful for debugging.
|
||||||
// See https://github.com/go-chi/chi/issues/332 for details and inspiration.
|
// See https://github.com/go-chi/chi/issues/332 for details and inspiration.
|
||||||
func (a *app) LogRoutes(r *chi.Mux) {
|
func (a *app) logRoutes(r *chi.Mux) {
|
||||||
desiredSpaces := 10
|
desiredSpaces := 10
|
||||||
|
|
||||||
walkFunc := func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error {
|
walkFunc := func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error {
|
||||||
|
@ -104,9 +103,7 @@ func (a *app) LogRoutes(r *chi.Mux) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var ErrDir = errors.New("path is dir")
|
func registerMimes() {
|
||||||
|
|
||||||
func init() {
|
|
||||||
err := mime.AddExtensionType(".js", "application/javascript")
|
err := mime.AddExtensionType(".js", "application/javascript")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -118,48 +115,38 @@ func init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryRead(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error {
|
// notFoundHandler perform the main logic around handling the internal SPA embed and ensuring that
|
||||||
f, err := fs.Open(path.Join(prefix, requestedPath))
|
// the client side routing is handled correctly.
|
||||||
if err != nil {
|
func notFoundHandler() http.HandlerFunc {
|
||||||
return err
|
tryRead := func(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error {
|
||||||
}
|
f, err := fs.Open(path.Join(prefix, requestedPath))
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
stat, _ := f.Stat()
|
|
||||||
if stat.IsDir() {
|
|
||||||
return ErrDir
|
|
||||||
}
|
|
||||||
|
|
||||||
contentType := mime.TypeByExtension(filepath.Ext(requestedPath))
|
|
||||||
w.Header().Set("Content-Type", contentType)
|
|
||||||
_, err = io.Copy(w, f)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
err := tryRead(public, "public", r.URL.Path, w)
|
|
||||||
if err == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Debug().
|
|
||||||
Str("path", r.URL.Path).
|
|
||||||
Msg("served from embed not found - serving index.html")
|
|
||||||
err = tryRead(public, "public", "index.html", w)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func DumpEmbedContents() {
|
|
||||||
// recursively prints all contents in the embed.FS
|
|
||||||
err := fs.WalkDir(public, ".", func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println(path)
|
defer f.Close()
|
||||||
return nil
|
|
||||||
})
|
stat, _ := f.Stat()
|
||||||
if err != nil {
|
if stat.IsDir() {
|
||||||
panic(err)
|
return ErrDir
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := mime.TypeByExtension(filepath.Ext(requestedPath))
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
_, err = io.Copy(w, f)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := tryRead(public, "public", r.URL.Path, w)
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Debug().
|
||||||
|
Str("path", r.URL.Path).
|
||||||
|
Msg("served from embed not found - serving index.html")
|
||||||
|
err = tryRead(public, "public", "index.html", w)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,96 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/hay-kot/content/backend/ent"
|
|
||||||
"github.com/hay-kot/content/backend/internal/repo"
|
|
||||||
"github.com/hay-kot/content/backend/internal/types"
|
|
||||||
"github.com/hay-kot/content/backend/pkgs/hasher"
|
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DefaultGroup = "Default"
|
|
||||||
DefaultName = "Admin"
|
|
||||||
DefaultEmail = "admin@admin.com"
|
|
||||||
DefaultPassword = "admin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// EnsureAdministrator ensures that there is at least one superuser in the database
|
|
||||||
// if one isn't found a default is generate using the default credentials
|
|
||||||
func (a *app) EnsureAdministrator() {
|
|
||||||
superusers, err := a.repos.Users.GetSuperusers(context.Background())
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to get superusers")
|
|
||||||
}
|
|
||||||
if len(superusers) > 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pw, _ := hasher.HashPassword(DefaultPassword)
|
|
||||||
newSuperUser := types.UserCreate{
|
|
||||||
Name: DefaultName,
|
|
||||||
Email: DefaultEmail,
|
|
||||||
IsSuperuser: true,
|
|
||||||
Password: pw,
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().
|
|
||||||
Str("name", newSuperUser.Name).
|
|
||||||
Str("email", newSuperUser.Email).
|
|
||||||
Msg("no superusers found, creating default superuser")
|
|
||||||
|
|
||||||
_, err = a.repos.Users.Create(context.Background(), newSuperUser)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to create default superuser")
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *app) SeedDatabase(repos *repo.AllRepos) {
|
|
||||||
if !a.conf.Seed.Enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
group, err := repos.Groups.Create(context.Background(), DefaultGroup)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to create default group")
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, seedUser := range a.conf.Seed.Users {
|
|
||||||
|
|
||||||
// Check if User Exists
|
|
||||||
usr, err := repos.Users.GetOneEmail(context.Background(), seedUser.Email)
|
|
||||||
if err != nil && !ent.IsNotFound(err) {
|
|
||||||
log.Fatal().Err(err).Msg("failed to get user")
|
|
||||||
}
|
|
||||||
|
|
||||||
if usr != nil && usr.ID != uuid.Nil {
|
|
||||||
log.Info().Str("email", seedUser.Email).Msg("user already exists, skipping")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPw, err := hasher.HashPassword(seedUser.Password)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to hash password")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = repos.Users.Create(context.Background(), types.UserCreate{
|
|
||||||
Name: seedUser.Name,
|
|
||||||
Email: seedUser.Email,
|
|
||||||
IsSuperuser: seedUser.IsSuperuser,
|
|
||||||
Password: hashedPw,
|
|
||||||
GroupID: group.ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal().Err(err).Msg("failed to create user")
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Info().Str("email", seedUser.Email).Msg("created user")
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,6 +3,7 @@ package v1
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hay-kot/content/backend/ent"
|
||||||
"github.com/hay-kot/content/backend/internal/services"
|
"github.com/hay-kot/content/backend/internal/services"
|
||||||
"github.com/hay-kot/content/backend/internal/types"
|
"github.com/hay-kot/content/backend/internal/types"
|
||||||
"github.com/hay-kot/content/backend/pkgs/server"
|
"github.com/hay-kot/content/backend/pkgs/server"
|
||||||
|
@ -101,6 +102,13 @@ func (ctrl *V1Controller) HandleLabelGet() http.HandlerFunc {
|
||||||
|
|
||||||
labels, err := ctrl.svc.Labels.Get(r.Context(), user.GroupID, uid)
|
labels, err := ctrl.svc.Labels.Get(r.Context(), user.GroupID, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ent.IsNotFound(err) {
|
||||||
|
log.Err(err).
|
||||||
|
Str("id", uid.String()).
|
||||||
|
Msg("label not found")
|
||||||
|
server.RespondError(w, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Err(err).Msg("error getting label")
|
log.Err(err).Msg("error getting label")
|
||||||
server.RespondServerError(w)
|
server.RespondServerError(w)
|
||||||
return
|
return
|
||||||
|
|
|
@ -3,6 +3,7 @@ package v1
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hay-kot/content/backend/ent"
|
||||||
"github.com/hay-kot/content/backend/internal/services"
|
"github.com/hay-kot/content/backend/internal/services"
|
||||||
"github.com/hay-kot/content/backend/internal/types"
|
"github.com/hay-kot/content/backend/internal/types"
|
||||||
"github.com/hay-kot/content/backend/pkgs/server"
|
"github.com/hay-kot/content/backend/pkgs/server"
|
||||||
|
@ -101,6 +102,14 @@ func (ctrl *V1Controller) HandleLocationGet() http.HandlerFunc {
|
||||||
|
|
||||||
location, err := ctrl.svc.Location.GetOne(r.Context(), user.GroupID, uid)
|
location, err := ctrl.svc.Location.GetOne(r.Context(), user.GroupID, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ent.IsNotFound(err) {
|
||||||
|
log.Err(err).
|
||||||
|
Str("id", uid.String()).
|
||||||
|
Msg("location not found")
|
||||||
|
server.RespondError(w, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Err(err).Msg("failed to get location")
|
log.Err(err).Msg("failed to get location")
|
||||||
server.RespondServerError(w)
|
server.RespondServerError(w)
|
||||||
return
|
return
|
||||||
|
|
|
@ -5,28 +5,15 @@ swagger:
|
||||||
scheme: http
|
scheme: http
|
||||||
web:
|
web:
|
||||||
port: 7745
|
port: 7745
|
||||||
host:
|
host: ""
|
||||||
database:
|
database:
|
||||||
driver: sqlite3
|
driver: sqlite3
|
||||||
sqlite-url: ./ent.db?_fk=1
|
sqlite-url: ./homebox.db?_fk=1
|
||||||
logger:
|
logger:
|
||||||
level: debug
|
level: debug
|
||||||
file: api.log
|
|
||||||
mailer:
|
mailer:
|
||||||
host: smtp.example.com
|
host: smtp.example.com
|
||||||
port: 465
|
port: 465
|
||||||
username:
|
username:
|
||||||
password:
|
password:
|
||||||
from: example@email.com
|
from: example@email.com
|
||||||
seed:
|
|
||||||
enabled: true
|
|
||||||
group: Default
|
|
||||||
users:
|
|
||||||
- name: Admin
|
|
||||||
email: admin@admin.com
|
|
||||||
password: admin
|
|
||||||
isSuperuser: true
|
|
||||||
- name: User
|
|
||||||
email: user@user.com
|
|
||||||
password: user
|
|
||||||
isSuperuser: false
|
|
||||||
|
|
|
@ -22,7 +22,6 @@ type Config struct {
|
||||||
Database Database `yaml:"database"`
|
Database Database `yaml:"database"`
|
||||||
Log LoggerConf `yaml:"logger"`
|
Log LoggerConf `yaml:"logger"`
|
||||||
Mailer MailerConf `yaml:"mailer"`
|
Mailer MailerConf `yaml:"mailer"`
|
||||||
Seed Seed `yaml:"seed"`
|
|
||||||
Swagger SwaggerConf `yaml:"swagger"`
|
Swagger SwaggerConf `yaml:"swagger"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
package config
|
package config
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DriverSqlite3 = "sqlite3"
|
DriverSqlite3 = "sqlite3"
|
||||||
DriverPostgres = "postgres"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Database struct {
|
type Database struct {
|
||||||
Driver string `yaml:"driver" conf:"default:sqlite3"`
|
Driver string `yaml:"driver" conf:"default:sqlite3"`
|
||||||
SqliteUrl string `yaml:"sqlite-url" conf:"default:file:ent?mode=memory&cache=shared&_fk=1"`
|
SqliteUrl string `yaml:"sqlite-url" conf:"default:file:ent?mode=memory&cache=shared&_fk=1"`
|
||||||
PostgresUrl string `yaml:"postgres-url" conf:""`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) GetDriver() string {
|
func (d *Database) GetDriver() string {
|
||||||
|
@ -19,8 +17,6 @@ func (d *Database) GetUrl() string {
|
||||||
switch d.Driver {
|
switch d.Driver {
|
||||||
case DriverSqlite3:
|
case DriverSqlite3:
|
||||||
return d.SqliteUrl
|
return d.SqliteUrl
|
||||||
case DriverPostgres:
|
|
||||||
return d.PostgresUrl
|
|
||||||
default:
|
default:
|
||||||
panic("unknown database driver")
|
panic("unknown database driver")
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,16 +16,6 @@ func Test_DatabaseConfig_Sqlite(t *testing.T) {
|
||||||
assert.Equal(t, "file:ent?mode=memory&cache=shared&_fk=1", dbConf.GetUrl())
|
assert.Equal(t, "file:ent?mode=memory&cache=shared&_fk=1", dbConf.GetUrl())
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_DatabaseConfig_Postgres(t *testing.T) {
|
|
||||||
dbConf := &Database{
|
|
||||||
Driver: DriverPostgres,
|
|
||||||
PostgresUrl: "postgres://user:pass@host:port/dbname?sslmode=disable",
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, "postgres", dbConf.GetDriver())
|
|
||||||
assert.Equal(t, "postgres://user:pass@host:port/dbname?sslmode=disable", dbConf.GetUrl())
|
|
||||||
}
|
|
||||||
|
|
||||||
func Test_DatabaseConfig_Unknown(t *testing.T) {
|
func Test_DatabaseConfig_Unknown(t *testing.T) {
|
||||||
dbConf := &Database{
|
dbConf := &Database{
|
||||||
Driver: "null",
|
Driver: "null",
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
package config
|
|
||||||
|
|
||||||
type SeedUser struct {
|
|
||||||
Name string `yaml:"name"`
|
|
||||||
Email string `yaml:"email"`
|
|
||||||
Password string `yaml:"password"`
|
|
||||||
IsSuperuser bool `yaml:"isSuperuser"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type Seed struct {
|
|
||||||
Enabled bool `yaml:"enabled" conf:"default:false"`
|
|
||||||
Users []SeedUser `yaml:"users"`
|
|
||||||
Group string `yaml:"group"`
|
|
||||||
}
|
|
|
@ -28,18 +28,11 @@ func (e *EntUserRepository) GetOneEmail(ctx context.Context, email string) (*ent
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EntUserRepository) GetAll(ctx context.Context) ([]*ent.User, error) {
|
func (e *EntUserRepository) GetAll(ctx context.Context) ([]*ent.User, error) {
|
||||||
users, err := e.db.User.Query().WithGroup().All(ctx)
|
return e.db.User.Query().WithGroup().All(ctx)
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return users, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (*ent.User, error) {
|
func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (*ent.User, error) {
|
||||||
err := usr.Validate()
|
err := usr.Validate()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &ent.User{}, err
|
return &ent.User{}, err
|
||||||
}
|
}
|
||||||
|
@ -52,7 +45,6 @@ func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (*
|
||||||
SetIsSuperuser(usr.IsSuperuser).
|
SetIsSuperuser(usr.IsSuperuser).
|
||||||
SetGroupID(usr.GroupID).
|
SetGroupID(usr.GroupID).
|
||||||
Save(ctx)
|
Save(ctx)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return entUser, err
|
return entUser, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,8 @@ type UserService struct {
|
||||||
repos *repo.AllRepos
|
repos *repo.AllRepos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RegisterUser creates a new user and group in the data with the provided data. It also bootstraps the user's group
|
||||||
|
// with default Labels and Locations.
|
||||||
func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistration) (*types.UserOut, error) {
|
func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistration) (*types.UserOut, error) {
|
||||||
group, err := svc.repos.Groups.Create(ctx, data.GroupName)
|
group, err := svc.repos.Groups.Create(ctx, data.GroupName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -38,7 +40,26 @@ func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistr
|
||||||
GroupID: group.ID,
|
GroupID: group.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return mappers.ToOutUser(svc.repos.Users.Create(ctx, usrCreate))
|
usr, err := svc.repos.Users.Create(ctx, usrCreate)
|
||||||
|
if err != nil {
|
||||||
|
return &types.UserOut{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, label := range defaultLabels() {
|
||||||
|
_, err := svc.repos.Labels.Create(ctx, group.ID, label)
|
||||||
|
if err != nil {
|
||||||
|
return &types.UserOut{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, location := range defaultLocations() {
|
||||||
|
_, err := svc.repos.Locations.Create(ctx, group.ID, location)
|
||||||
|
if err != nil {
|
||||||
|
return &types.UserOut{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappers.ToOutUser(usr, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSelf returns the user that is currently logged in based of the token provided within
|
// GetSelf returns the user that is currently logged in based of the token provided within
|
||||||
|
|
55
backend/internal/services/service_user_defaults.go
Normal file
55
backend/internal/services/service_user_defaults.go
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
package services
|
||||||
|
|
||||||
|
import "github.com/hay-kot/content/backend/internal/types"
|
||||||
|
|
||||||
|
func defaultLocations() []types.LocationCreate {
|
||||||
|
return []types.LocationCreate{
|
||||||
|
{
|
||||||
|
Name: "Living Room",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Garage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Kitchen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Bedroom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Bathroom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Office",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Attic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Basement",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultLabels() []types.LabelCreate {
|
||||||
|
return []types.LabelCreate{
|
||||||
|
{
|
||||||
|
Name: "Appliances",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "IOT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Electronics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Servers",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "General",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Important",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Iconify v-if="icon" :icon="icon" class="inline-block w-5 h-5" />
|
<Iconify v-if="icon" :icon="icon" class="inline-block" />
|
||||||
<Component :is="component" v-else-if="component" />
|
<Component :is="component" v-else-if="component" />
|
||||||
<span v-else>{{ name }}</span>
|
<span v-else>{{ name }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,23 +1,27 @@
|
||||||
import { PublicApi } from "~~/lib/api/public";
|
import { PublicApi } from '~~/lib/api/public';
|
||||||
import { UserApi } from "~~/lib/api/user";
|
import { UserApi } from '~~/lib/api/user';
|
||||||
import { Requests } from "~~/lib/requests";
|
import { Requests } from '~~/lib/requests';
|
||||||
import { useAuthStore } from "~~/stores/auth";
|
import { useAuthStore } from '~~/stores/auth';
|
||||||
|
|
||||||
async function ApiDebugger(r: Response) {
|
function logger(r: Response) {
|
||||||
console.table({
|
console.log(`${r.status} ${r.url} ${r.statusText}`);
|
||||||
"Request Url": r.url,
|
|
||||||
"Response Status": r.status,
|
|
||||||
"Response Status Text": r.statusText,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePublicApi(): PublicApi {
|
export function usePublicApi(): PublicApi {
|
||||||
const requests = new Requests("", "", {}, ApiDebugger);
|
const requests = new Requests('', '', {});
|
||||||
return new PublicApi(requests);
|
return new PublicApi(requests);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserApi(): UserApi {
|
export function useUserApi(): UserApi {
|
||||||
const authStore = useAuthStore();
|
const authStore = useAuthStore();
|
||||||
const requests = new Requests("", () => authStore.token, {}, ApiDebugger);
|
|
||||||
|
const requests = new Requests('', () => authStore.token, {});
|
||||||
|
requests.addResponseInterceptor(logger);
|
||||||
|
requests.addResponseInterceptor(r => {
|
||||||
|
if (r.status === 401) {
|
||||||
|
authStore.clearSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return new UserApi(requests);
|
return new UserApi(requests);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,8 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, test, expect } from 'vitest';
|
||||||
import { Requests } from '../../requests';
|
import { client, userClient } from './test-utils';
|
||||||
import { OverrideParts } from '../base/urls';
|
|
||||||
import { PublicApi } from '../public';
|
|
||||||
import * as config from '../../../test/config';
|
|
||||||
import { UserApi } from '../user';
|
|
||||||
|
|
||||||
function client() {
|
|
||||||
OverrideParts(config.BASE_URL, '/api/v1');
|
|
||||||
const requests = new Requests('');
|
|
||||||
return new PublicApi(requests);
|
|
||||||
}
|
|
||||||
|
|
||||||
function userClient(token: string) {
|
|
||||||
OverrideParts(config.BASE_URL, '/api/v1');
|
|
||||||
const requests = new Requests('', token);
|
|
||||||
return new UserApi(requests);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('[GET] /api/v1/status', () => {
|
describe('[GET] /api/v1/status', () => {
|
||||||
it('basic query parameter', async () => {
|
test('server should respond', async () => {
|
||||||
const api = client();
|
const api = client();
|
||||||
const { response, data } = await api.status();
|
const { response, data } = await api.status();
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
|
@ -37,12 +21,12 @@ describe('first time user workflow (register, login)', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
it('user should be able to register', async () => {
|
test('user should be able to register', async () => {
|
||||||
const { response } = await api.register(userData);
|
const { response } = await api.register(userData);
|
||||||
expect(response.status).toBe(204);
|
expect(response.status).toBe(204);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('user should be able to login', async () => {
|
test('user should be able to login', async () => {
|
||||||
const { response, data } = await api.login(userData.user.email, userData.user.password);
|
const { response, data } = await api.login(userData.user.email, userData.user.password);
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(data.token).toBeTruthy();
|
expect(data.token).toBeTruthy();
|
||||||
|
|
61
frontend/lib/api/__test__/test-utils.ts
Normal file
61
frontend/lib/api/__test__/test-utils.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
import { beforeAll, expect } from 'vitest';
|
||||||
|
import { Requests } from '../../requests';
|
||||||
|
import { overrideParts } from '../base/urls';
|
||||||
|
import { PublicApi } from '../public';
|
||||||
|
import * as config from '../../../test/config';
|
||||||
|
import { UserApi } from '../user';
|
||||||
|
|
||||||
|
export function client() {
|
||||||
|
overrideParts(config.BASE_URL, '/api/v1');
|
||||||
|
const requests = new Requests('');
|
||||||
|
return new PublicApi(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userClient(token: string) {
|
||||||
|
overrideParts(config.BASE_URL, '/api/v1');
|
||||||
|
const requests = new Requests('', token);
|
||||||
|
return new UserApi(requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cache = {
|
||||||
|
token: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shared UserApi token for tests where the creation of a user is _not_ import
|
||||||
|
* to the test. This is useful for tests that are testing the user API itself.
|
||||||
|
*/
|
||||||
|
export async function sharedUserClient(): Promise<UserApi> {
|
||||||
|
if (cache.token) {
|
||||||
|
return userClient(cache.token);
|
||||||
|
}
|
||||||
|
const testUser = {
|
||||||
|
groupName: 'test-group',
|
||||||
|
user: {
|
||||||
|
email: '__test__@__test__.com',
|
||||||
|
name: '__test__',
|
||||||
|
password: '__test__',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = client();
|
||||||
|
const { response: tryLoginResp, data } = await api.login(testUser.user.email, testUser.user.password);
|
||||||
|
|
||||||
|
if (tryLoginResp.status === 200) {
|
||||||
|
cache.token = data.token;
|
||||||
|
return userClient(cache.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response: registerResp } = await api.register(testUser);
|
||||||
|
expect(registerResp.status).toBe(204);
|
||||||
|
|
||||||
|
const { response: loginResp, data: loginData } = await api.login(testUser.user.email, testUser.user.password);
|
||||||
|
expect(loginResp.status).toBe(200);
|
||||||
|
|
||||||
|
cache.token = loginData.token;
|
||||||
|
return userClient(data.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await sharedUserClient();
|
||||||
|
});
|
92
frontend/lib/api/__test__/user/labels.test.ts
Normal file
92
frontend/lib/api/__test__/user/labels.test.ts
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { Label } from '../../classes/labels';
|
||||||
|
import { UserApi } from '../../user';
|
||||||
|
import { sharedUserClient } from '../test-utils';
|
||||||
|
|
||||||
|
describe('locations lifecycle (create, update, delete)', () => {
|
||||||
|
let increment = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useLabel sets up a label resource for testing, and returns a function
|
||||||
|
* that can be used to delete the label from the backend server.
|
||||||
|
*/
|
||||||
|
async function useLabel(api: UserApi): Promise<[Label, () => Promise<void>]> {
|
||||||
|
const { response, data } = await api.labels.create({
|
||||||
|
name: `__test__.label.name_${increment}`,
|
||||||
|
description: `__test__.label.description_${increment}`,
|
||||||
|
color: '',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
increment++;
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const { response } = await api.labels.delete(data.id);
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
};
|
||||||
|
return [data, cleanup];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('user should be able to create a label', async () => {
|
||||||
|
const api = await sharedUserClient();
|
||||||
|
|
||||||
|
const labelData = {
|
||||||
|
name: 'test-label',
|
||||||
|
description: 'test-description',
|
||||||
|
color: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { response, data } = await api.labels.create(labelData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(data.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Ensure we can get the label
|
||||||
|
const { response: getResponse, data: getData } = await api.labels.get(data.id);
|
||||||
|
|
||||||
|
expect(getResponse.status).toBe(200);
|
||||||
|
expect(getData.id).toBe(data.id);
|
||||||
|
expect(getData.name).toBe(labelData.name);
|
||||||
|
expect(getData.description).toBe(labelData.description);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
const { response: deleteResponse } = await api.labels.delete(data.id);
|
||||||
|
expect(deleteResponse.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user should be able to update a label', async () => {
|
||||||
|
const api = await sharedUserClient();
|
||||||
|
const [label, cleanup] = await useLabel(api);
|
||||||
|
|
||||||
|
const labelData = {
|
||||||
|
name: 'test-label',
|
||||||
|
description: 'test-description',
|
||||||
|
color: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { response, data } = await api.labels.update(label.id, labelData);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.id).toBe(label.id);
|
||||||
|
|
||||||
|
// Ensure we can get the label
|
||||||
|
const { response: getResponse, data: getData } = await api.labels.get(data.id);
|
||||||
|
expect(getResponse.status).toBe(200);
|
||||||
|
expect(getData.id).toBe(data.id);
|
||||||
|
expect(getData.name).toBe(labelData.name);
|
||||||
|
expect(getData.description).toBe(labelData.description);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user should be able to delete a label', async () => {
|
||||||
|
const api = await sharedUserClient();
|
||||||
|
const [label, _] = await useLabel(api);
|
||||||
|
|
||||||
|
const { response } = await api.labels.delete(label.id);
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
|
||||||
|
// Ensure we can't get the label
|
||||||
|
const { response: getResponse } = await api.labels.get(label.id);
|
||||||
|
expect(getResponse.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
89
frontend/lib/api/__test__/user/locations.test.ts
Normal file
89
frontend/lib/api/__test__/user/locations.test.ts
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { Location } from '../../classes/locations';
|
||||||
|
import { UserApi } from '../../user';
|
||||||
|
import { sharedUserClient } from '../test-utils';
|
||||||
|
|
||||||
|
describe('locations lifecycle (create, update, delete)', () => {
|
||||||
|
let increment = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useLocatio sets up a location resource for testing, and returns a function
|
||||||
|
* that can be used to delete the location from the backend server.
|
||||||
|
*/
|
||||||
|
async function useLocation(api: UserApi): Promise<[Location, () => Promise<void>]> {
|
||||||
|
const { response, data } = await api.locations.create({
|
||||||
|
name: `__test__.location.name_${increment}`,
|
||||||
|
description: `__test__.location.description_${increment}`,
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
increment++;
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const { response } = await api.locations.delete(data.id);
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [data, cleanup];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('user should be able to create a location', async () => {
|
||||||
|
const api = await sharedUserClient();
|
||||||
|
|
||||||
|
const locationData = {
|
||||||
|
name: 'test-location',
|
||||||
|
description: 'test-description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { response, data } = await api.locations.create(locationData);
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(data.id).toBeTruthy();
|
||||||
|
|
||||||
|
// Ensure we can get the location
|
||||||
|
const { response: getResponse, data: getData } = await api.locations.get(data.id);
|
||||||
|
|
||||||
|
expect(getResponse.status).toBe(200);
|
||||||
|
expect(getData.id).toBe(data.id);
|
||||||
|
expect(getData.name).toBe(locationData.name);
|
||||||
|
expect(getData.description).toBe(locationData.description);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
const { response: deleteResponse } = await api.locations.delete(data.id);
|
||||||
|
expect(deleteResponse.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user should be able to update a location', async () => {
|
||||||
|
const api = await sharedUserClient();
|
||||||
|
const [location, cleanup] = await useLocation(api);
|
||||||
|
|
||||||
|
const updateData = {
|
||||||
|
name: 'test-location-updated',
|
||||||
|
description: 'test-description-updated',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { response } = await api.locations.update(location.id, updateData);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
// Ensure we can get the location
|
||||||
|
const { response: getResponse, data } = await api.locations.get(location.id);
|
||||||
|
expect(getResponse.status).toBe(200);
|
||||||
|
|
||||||
|
expect(data.id).toBe(location.id);
|
||||||
|
expect(data.name).toBe(updateData.name);
|
||||||
|
expect(data.description).toBe(updateData.description);
|
||||||
|
|
||||||
|
await cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user should be able to delete a location', async () => {
|
||||||
|
const api = await sharedUserClient();
|
||||||
|
const [location, _] = await useLocation(api);
|
||||||
|
|
||||||
|
const { response } = await api.locations.delete(location.id);
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
|
||||||
|
// Ensure we can't get the location
|
||||||
|
const { response: getResponse } = await api.locations.get(location.id);
|
||||||
|
expect(getResponse.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,24 +1,24 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { UrlBuilder } from '.';
|
import { route } from '.';
|
||||||
|
|
||||||
describe('UrlBuilder', () => {
|
describe('UrlBuilder', () => {
|
||||||
it('basic query parameter', () => {
|
it('basic query parameter', () => {
|
||||||
const result = UrlBuilder('/test', { a: 'b' });
|
const result = route('/test', { a: 'b' });
|
||||||
expect(result).toBe('/api/v1/test?a=b');
|
expect(result).toBe('/api/v1/test?a=b');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('multiple query parameters', () => {
|
it('multiple query parameters', () => {
|
||||||
const result = UrlBuilder('/test', { a: 'b', c: 'd' });
|
const result = route('/test', { a: 'b', c: 'd' });
|
||||||
expect(result).toBe('/api/v1/test?a=b&c=d');
|
expect(result).toBe('/api/v1/test?a=b&c=d');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no query parameters', () => {
|
it('no query parameters', () => {
|
||||||
const result = UrlBuilder('/test');
|
const result = route('/test');
|
||||||
expect(result).toBe('/api/v1/test');
|
expect(result).toBe('/api/v1/test');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list-like query parameters', () => {
|
it('list-like query parameters', () => {
|
||||||
const result = UrlBuilder('/test', { a: ['b', 'c'] });
|
const result = route('/test', { a: ['b', 'c'] });
|
||||||
expect(result).toBe('/api/v1/test?a=b&a=c');
|
expect(result).toBe('/api/v1/test?a=b&a=c');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
export { BaseAPI } from './base-api';
|
export { BaseAPI } from './base-api';
|
||||||
export { UrlBuilder } from './urls';
|
export { route } from './urls';
|
||||||
|
|
|
@ -3,14 +3,23 @@ const parts = {
|
||||||
prefix: '/api/v1',
|
prefix: '/api/v1',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OverrideParts(host: string, prefix: string) {
|
export function overrideParts(host: string, prefix: string) {
|
||||||
parts.host = host;
|
parts.host = host;
|
||||||
parts.prefix = prefix;
|
parts.prefix = prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueryValue = string | string[] | number | number[] | boolean | null | undefined;
|
export type QueryValue = string | string[] | number | number[] | boolean | null | undefined;
|
||||||
|
|
||||||
export function UrlBuilder(rest: string, params: Record<string, QueryValue> = {}): string {
|
/**
|
||||||
|
* route is a the main URL builder for the API. It will use a predefined host and prefix (global)
|
||||||
|
* in the urls.ts file and then append the passed in path parameter uring the `URL` class from the
|
||||||
|
* browser. It will also append any query parameters passed in as the second parameter.
|
||||||
|
*
|
||||||
|
* The default host `http://localhost.com` is removed from the path if it is present. This allows us
|
||||||
|
* to bootstrap the API with different hosts as needed (like for testing) but still allows us to use
|
||||||
|
* relative URLs in pruduction because the API and client bundle are served from the same server/host.
|
||||||
|
*/
|
||||||
|
export function route(rest: string, params: Record<string, QueryValue> = {}): string {
|
||||||
const url = new URL(parts.prefix + rest, parts.host);
|
const url = new URL(parts.prefix + rest, parts.host);
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(params)) {
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
@ -23,6 +32,5 @@ export function UrlBuilder(rest: string, params: Record<string, QueryValue> = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// we return the path only, without the base URL
|
|
||||||
return url.toString().replace('http://localhost.com', '');
|
return url.toString().replace('http://localhost.com', '');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAPI, UrlBuilder } from '../base';
|
import { BaseAPI, route } from '../base';
|
||||||
import { Label } from './labels';
|
import { Label } from './labels';
|
||||||
import { Location } from './locations';
|
import { Location } from './locations';
|
||||||
import { Results } from './types';
|
import { Results } from './types';
|
||||||
|
@ -33,22 +33,22 @@ export interface Item {
|
||||||
|
|
||||||
export class ItemsApi extends BaseAPI {
|
export class ItemsApi extends BaseAPI {
|
||||||
async getAll() {
|
async getAll() {
|
||||||
return this.http.get<Results<Item>>(UrlBuilder('/items'));
|
return this.http.get<Results<Item>>(route('/items'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(item: ItemCreate) {
|
async create(item: ItemCreate) {
|
||||||
return this.http.post<ItemCreate, Item>(UrlBuilder('/items'), item);
|
return this.http.post<ItemCreate, Item>(route('/items'), item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
return this.http.get<Item>(UrlBuilder(`/items/${id}`));
|
return this.http.get<Item>(route(`/items/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
return this.http.delete<void>(UrlBuilder(`/items/${id}`));
|
return this.http.delete<void>(route(`/items/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, item: ItemCreate) {
|
async update(id: string, item: ItemCreate) {
|
||||||
return this.http.put<ItemCreate, Item>(UrlBuilder(`/items/${id}`), item);
|
return this.http.put<ItemCreate, Item>(route(`/items/${id}`), item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAPI, UrlBuilder } from '../base';
|
import { BaseAPI, route } from '../base';
|
||||||
import { Details, OutType, Results } from './types';
|
import { Details, OutType, Results } from './types';
|
||||||
|
|
||||||
export type LabelCreate = Details & {
|
export type LabelCreate = Details & {
|
||||||
|
@ -14,22 +14,22 @@ export type Label = LabelCreate &
|
||||||
|
|
||||||
export class LabelsApi extends BaseAPI {
|
export class LabelsApi extends BaseAPI {
|
||||||
async getAll() {
|
async getAll() {
|
||||||
return this.http.get<Results<Label>>(UrlBuilder('/labels'));
|
return this.http.get<Results<Label>>(route('/labels'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(label: LabelCreate) {
|
async create(label: LabelCreate) {
|
||||||
return this.http.post<LabelCreate, Label>(UrlBuilder('/labels'), label);
|
return this.http.post<LabelCreate, Label>(route('/labels'), label);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
return this.http.get<Label>(UrlBuilder(`/labels/${id}`));
|
return this.http.get<Label>(route(`/labels/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
return this.http.delete<void>(UrlBuilder(`/labels/${id}`));
|
return this.http.delete<void>(route(`/labels/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, label: LabelUpdate) {
|
async update(id: string, label: LabelUpdate) {
|
||||||
return this.http.put<LabelUpdate, Label>(UrlBuilder(`/labels/${id}`), label);
|
return this.http.put<LabelUpdate, Label>(route(`/labels/${id}`), label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAPI, UrlBuilder } from '../base';
|
import { BaseAPI, route } from '../base';
|
||||||
import { Item } from './items';
|
import { Item } from './items';
|
||||||
import { Details, OutType, Results } from './types';
|
import { Details, OutType, Results } from './types';
|
||||||
|
|
||||||
|
@ -15,21 +15,21 @@ export type LocationUpdate = LocationCreate;
|
||||||
|
|
||||||
export class LocationsApi extends BaseAPI {
|
export class LocationsApi extends BaseAPI {
|
||||||
async getAll() {
|
async getAll() {
|
||||||
return this.http.get<Results<Location>>(UrlBuilder('/locations'));
|
return this.http.get<Results<Location>>(route('/locations'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(location: LocationCreate) {
|
async create(location: LocationCreate) {
|
||||||
return this.http.post<LocationCreate, Location>(UrlBuilder('/locations'), location);
|
return this.http.post<LocationCreate, Location>(route('/locations'), location);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(id: string) {
|
async get(id: string) {
|
||||||
return this.http.get<Location>(UrlBuilder(`/locations/${id}`));
|
return this.http.get<Location>(route(`/locations/${id}`));
|
||||||
}
|
}
|
||||||
async delete(id: string) {
|
async delete(id: string) {
|
||||||
return this.http.delete<void>(UrlBuilder(`/locations/${id}`));
|
return this.http.delete<void>(route(`/locations/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, location: LocationUpdate) {
|
async update(id: string, location: LocationUpdate) {
|
||||||
return this.http.put<LocationUpdate, Location>(UrlBuilder(`/locations/${id}`), location);
|
return this.http.put<LocationUpdate, Location>(route(`/locations/${id}`), location);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { BaseAPI, UrlBuilder } from './base';
|
import { BaseAPI, route } from './base';
|
||||||
|
|
||||||
export type LoginResult = {
|
export type LoginResult = {
|
||||||
token: string;
|
token: string;
|
||||||
|
@ -28,17 +28,17 @@ export type StatusResult = {
|
||||||
|
|
||||||
export class PublicApi extends BaseAPI {
|
export class PublicApi extends BaseAPI {
|
||||||
public status() {
|
public status() {
|
||||||
return this.http.get<StatusResult>(UrlBuilder('/status'));
|
return this.http.get<StatusResult>(route('/status'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public login(username: string, password: string) {
|
public login(username: string, password: string) {
|
||||||
return this.http.post<LoginPayload, LoginResult>(UrlBuilder('/users/login'), {
|
return this.http.post<LoginPayload, LoginResult>(route('/users/login'), {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public register(payload: RegisterPayload) {
|
public register(payload: RegisterPayload) {
|
||||||
return this.http.post<RegisterPayload, LoginResult>(UrlBuilder('/users/register'), payload);
|
return this.http.post<RegisterPayload, LoginResult>(route('/users/register'), payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { Requests } from '~~/lib/requests';
|
import { Requests } from '~~/lib/requests';
|
||||||
import { BaseAPI, UrlBuilder } from './base';
|
import { BaseAPI, route } from './base';
|
||||||
import { ItemsApi } from './classes/items';
|
import { ItemsApi } from './classes/items';
|
||||||
import { LabelsApi } from './classes/labels';
|
import { LabelsApi } from './classes/labels';
|
||||||
import { LocationsApi } from './classes/locations';
|
import { LocationsApi } from './classes/locations';
|
||||||
|
@ -30,14 +30,14 @@ export class UserApi extends BaseAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
public self() {
|
public self() {
|
||||||
return this.http.get<Result<User>>(UrlBuilder('/users/self'));
|
return this.http.get<Result<User>>(route('/users/self'));
|
||||||
}
|
}
|
||||||
|
|
||||||
public logout() {
|
public logout() {
|
||||||
return this.http.post<object, void>(UrlBuilder('/users/logout'), {});
|
return this.http.post<object, void>(route('/users/logout'), {});
|
||||||
}
|
}
|
||||||
|
|
||||||
public deleteAccount() {
|
public deleteAccount() {
|
||||||
return this.http.delete<void>(UrlBuilder('/users/self'));
|
return this.http.delete<void>(route('/users/self'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,9 @@ export enum Method {
|
||||||
DELETE = 'DELETE',
|
DELETE = 'DELETE',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RequestInterceptor = (r: Response) => void;
|
||||||
|
export type ResponseInterceptor = (r: Response) => void;
|
||||||
|
|
||||||
export interface TResponse<T> {
|
export interface TResponse<T> {
|
||||||
status: number;
|
status: number;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
|
@ -16,22 +19,24 @@ export class Requests {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
private token: () => string;
|
private token: () => string;
|
||||||
private headers: Record<string, string> = {};
|
private headers: Record<string, string> = {};
|
||||||
private logger?: (response: Response) => void;
|
private responseInterceptors: ResponseInterceptor[] = [];
|
||||||
|
|
||||||
|
addResponseInterceptor(interceptor: ResponseInterceptor) {
|
||||||
|
this.responseInterceptors.push(interceptor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private callResponseInterceptors(response: Response) {
|
||||||
|
this.responseInterceptors.forEach(i => i(response));
|
||||||
|
}
|
||||||
|
|
||||||
private url(rest: string): string {
|
private url(rest: string): string {
|
||||||
return this.baseUrl + rest;
|
return this.baseUrl + rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(baseUrl: string, token: string | (() => string) = '', headers: Record<string, string> = {}) {
|
||||||
baseUrl: string,
|
|
||||||
token: string | (() => string) = '',
|
|
||||||
headers: Record<string, string> = {},
|
|
||||||
logger?: (response: Response) => void
|
|
||||||
) {
|
|
||||||
this.baseUrl = baseUrl;
|
this.baseUrl = baseUrl;
|
||||||
this.token = typeof token === 'string' ? () => token : token;
|
this.token = typeof token === 'string' ? () => token : token;
|
||||||
this.headers = headers;
|
this.headers = headers;
|
||||||
this.logger = logger;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get<T>(url: string): Promise<TResponse<T>> {
|
public get<T>(url: string): Promise<TResponse<T>> {
|
||||||
|
@ -73,10 +78,7 @@ export class Requests {
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(this.url(url), args);
|
const response = await fetch(this.url(url), args);
|
||||||
|
this.callResponseInterceptors(response);
|
||||||
if (this.logger) {
|
|
||||||
this.logger(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: T = await (async () => {
|
const data: T = await (async () => {
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
|
|
@ -44,7 +44,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseContainer class="space-y-16">
|
<BaseContainer class="space-y-16 pb-16">
|
||||||
<section aria-labelledby="profile-overview-title" class="mt-8">
|
<section aria-labelledby="profile-overview-title" class="mt-8">
|
||||||
<div class="overflow-hidden rounded-lg bg-white shadow">
|
<div class="overflow-hidden rounded-lg bg-white shadow">
|
||||||
<h2 class="sr-only" id="profile-overview-title">Profile Overview</h2>
|
<h2 class="sr-only" id="profile-overview-title">Profile Overview</h2>
|
||||||
|
|
|
@ -47,7 +47,7 @@
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
// Print Values of registerFields
|
// Print Values of registerFields
|
||||||
|
|
||||||
const { data, error } = await api.register({
|
const { error } = await api.register({
|
||||||
user: {
|
user: {
|
||||||
name: registerFields[0].value,
|
name: registerFields[0].value,
|
||||||
email: registerFields[1].value,
|
email: registerFields[1].value,
|
||||||
|
@ -58,11 +58,14 @@
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error('Problem registering user');
|
toast.error('Problem registering user');
|
||||||
} else {
|
return;
|
||||||
toast.success('User registered');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success('User registered');
|
||||||
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
loginFields[0].value = registerFields[1].value;
|
||||||
|
registerForm.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loginFields = [
|
const loginFields = [
|
||||||
|
@ -107,76 +110,106 @@
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<header class="sm:px-6 py-2 lg:p-14 sm:py-6">
|
<div class="fill-primary min-w-full absolute top-0 z-[-1]">
|
||||||
<h2 class="mt-1 text-4xl font-bold tracking-tight text-base-content sm:text-5xl lg:text-6xl flex">
|
<div class="bg-primary flex-col flex min-h-[20vh]" />
|
||||||
HomeB
|
<svg
|
||||||
<AppLogo class="w-12 -mb-4" style="padding-left: 3px; padding-right: 2px" />
|
class="fill-primary drop-shadow-xl"
|
||||||
x
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</h2>
|
viewBox="0 0 1440 320"
|
||||||
<p class="ml-1 text-lg text-base-content/50">Track, Organize, and Manage your Shit.</p>
|
preserveAspectRatio="none"
|
||||||
</header>
|
>
|
||||||
<div class="grid p-6 sm:place-items-center min-h-[50vh]">
|
|
||||||
<Transition name="slide-fade">
|
|
||||||
<form v-if="registerForm" @submit.prevent="registerUser">
|
|
||||||
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">Register</h2>
|
|
||||||
<TextField
|
|
||||||
v-for="field in registerFields"
|
|
||||||
v-model="field.value"
|
|
||||||
:label="field.label"
|
|
||||||
:key="field.label"
|
|
||||||
:type="field.type"
|
|
||||||
/>
|
|
||||||
<div class="card-actions justify-end">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary mt-2"
|
|
||||||
:class="loading ? 'loading' : ''"
|
|
||||||
:disabled="loading"
|
|
||||||
>
|
|
||||||
Register
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form v-else @submit.prevent="login">
|
|
||||||
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
|
||||||
<div class="card-body">
|
|
||||||
<h2 class="card-title">Login</h2>
|
|
||||||
<TextField
|
|
||||||
v-for="field in loginFields"
|
|
||||||
v-model="field.value"
|
|
||||||
:label="field.label"
|
|
||||||
:key="field.label"
|
|
||||||
:type="field.type"
|
|
||||||
/>
|
|
||||||
<div class="card-actions justify-end mt-2">
|
|
||||||
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Transition>
|
|
||||||
<div class="text-center mt-2">
|
|
||||||
<button @click="toggleLogin">
|
|
||||||
{{ registerForm ? 'Already a User? Login' : 'Not a User? Register' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-full absolute bottom-0 z-[-1]">
|
|
||||||
<svg class="fill-primary" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none" viewBox="0 0 1440 320">
|
|
||||||
<path
|
<path
|
||||||
fill-opacity="1"
|
fill-opacity="1"
|
||||||
d="M0,32L30,42.7C60,53,120,75,180,80C240,85,300,75,360,80C420,85,480,107,540,128C600,149,660,171,720,160C780,149,840,107,900,90.7C960,75,1020,85,1080,122.7C1140,160,1200,224,1260,234.7C1320,245,1380,203,1410,181.3L1440,160L1440,320L1410,320C1380,320,1320,320,1260,320C1200,320,1140,320,1080,320C1020,320,960,320,900,320C840,320,780,320,720,320C660,320,600,320,540,320C480,320,420,320,360,320C300,320,240,320,180,320C120,320,60,320,30,320L0,320Z"
|
d="M0,32L80,69.3C160,107,320,181,480,181.3C640,181,800,107,960,117.3C1120,128,1280,224,1360,272L1440,320L1440,0L1360,0C1280,0,1120,0,960,0C800,0,640,0,480,0C320,0,160,0,80,0L0,0Z"
|
||||||
/>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="bg-primary flex-col flex min-h-[32vh]">
|
</div>
|
||||||
<div class="mt-auto mx-auto mb-8">
|
<div>
|
||||||
<p class="text-center text-gray-200">© 2022 Contents. All Rights Reserved. Haybytes LLC</p>
|
<header class="p-4 sm:px-6 lg:p-14 sm:py-6 sm:flex sm:items-end mx-auto">
|
||||||
|
<div>
|
||||||
|
<h2 class="mt-1 text-4xl font-bold tracking-tight text-neutral-content sm:text-5xl lg:text-6xl flex">
|
||||||
|
HomeB
|
||||||
|
<AppLogo class="w-12 -mb-4" style="padding-left: 3px; padding-right: 2px" />
|
||||||
|
x
|
||||||
|
</h2>
|
||||||
|
<p class="ml-1 text-lg text-base-content/50">Track, Organize, and Manage your Shit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex mt-6 sm:mt-0 gap-4 ml-auto text-neutral-content">
|
||||||
|
<a class="tooltip" data-tip="Project Github" href="https://github.com/hay-kot/homebox" target="_blank">
|
||||||
|
<Icon name="mdi-github" class="h-8 w-8" />
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com/haybytes" class="tooltip" data-tip="Follow The Developer" target="_blank">
|
||||||
|
<Icon name="mdi-twitter" class="h-8 w-8" />
|
||||||
|
</a>
|
||||||
|
<a href="/" class="tooltip" data-tip="Join The Discord">
|
||||||
|
<Icon name="mdi-discord" class="h-8 w-8" />
|
||||||
|
</a>
|
||||||
|
<a href="/" class="tooltip" data-tip="Read The Docs">
|
||||||
|
<Icon name="mdi-folder" class="h-8 w-8" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="grid p-6 sm:place-items-center min-h-[50vh]">
|
||||||
|
<div>
|
||||||
|
<Transition name="slide-fade">
|
||||||
|
<form v-if="registerForm" @submit.prevent="registerUser">
|
||||||
|
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl align-center">
|
||||||
|
<Icon name="heroicons-user" class="mr-1 w-7 h-7" />
|
||||||
|
Register
|
||||||
|
</h2>
|
||||||
|
<TextField
|
||||||
|
v-for="field in registerFields"
|
||||||
|
v-model="field.value"
|
||||||
|
:label="field.label"
|
||||||
|
:key="field.label"
|
||||||
|
:type="field.type"
|
||||||
|
/>
|
||||||
|
<div class="card-actions justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary mt-2"
|
||||||
|
:class="loading ? 'loading' : ''"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form v-else @submit.prevent="login">
|
||||||
|
<div class="card w-max-[500px] md:w-[500px] bg-base-100 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<h2 class="card-title text-2xl align-center">
|
||||||
|
<Icon name="heroicons-user" class="mr-1 w-7 h-7" />
|
||||||
|
Login
|
||||||
|
</h2>
|
||||||
|
<TextField
|
||||||
|
v-for="field in loginFields"
|
||||||
|
v-model="field.value"
|
||||||
|
:label="field.label"
|
||||||
|
:key="field.label"
|
||||||
|
:type="field.type"
|
||||||
|
/>
|
||||||
|
<div class="card-actions justify-end mt-2">
|
||||||
|
<button type="submit" class="btn btn-primary" :class="loading ? 'loading' : ''" :disabled="loading">
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Transition>
|
||||||
|
<div class="text-center mt-6">
|
||||||
|
<button
|
||||||
|
@click="toggleLogin"
|
||||||
|
class="text-base-content text-lg hover:bg-primary hover:text-primary-content px-3 py-1 rounded-xl transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{{ registerForm ? 'Already a User? Login' : 'Not a User? Register' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -33,5 +33,14 @@ export const useAuthStore = defineStore('auth', {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* clearSession is used when the user cannot be logged out via the API and
|
||||||
|
* must clear it's local session, usually when a 401 is received.
|
||||||
|
*/
|
||||||
|
clearSession() {
|
||||||
|
this.token = '';
|
||||||
|
this.expires = '';
|
||||||
|
navigateTo('/');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue