From 98dcdf7fa69928a5e396ed6e854096e6e2d46788 Mon Sep 17 00:00:00 2001 From: Sudarsh1010 Date: Sun, 24 Nov 2024 16:49:03 +0530 Subject: [PATCH] temp commi --- :w | 25 ++ docker-compose.yml | 12 + internal/app/container.go | 27 +- internal/app/controllers.go | 8 +- internal/app/middlewares.go | 15 + internal/constants/constants.go | 3 + internal/controllers/account_controller.go | 55 +++ internal/controllers/applicaton_controller.go | 57 +++ internal/controllers/auth_controller.go | 23 +- internal/database/database.go | 39 +- internal/middlewares/auth_middleware.go | 42 +++ internal/models/account.go | 48 +++ internal/models/application.go | 27 ++ internal/models/application_auth_provider.go | 29 ++ internal/models/application_environment.go | 22 ++ internal/models/main.go | 4 +- internal/models/user.go | 55 ++- internal/repositories/account_repository.go | 68 ++++ .../repositories/application_repository.go | 53 +++ .../repositories/user_account_repository.go | 26 ++ internal/repositories/user_repository.go | 13 - internal/server/routes.go | 12 +- internal/server/server.go | 3 + internal/services/account_service.go | 52 +++ internal/services/application_service.go | 65 ++++ internal/services/auth_service.go | 15 +- internal/services/session_service.go | 5 +- internal/utils/session_helpers.go | 11 +- internal/validators/account.go | 31 ++ internal/validators/application.go | 31 ++ internal/validators/{sign_up.go => auth.go} | 15 +- internal/validators/sign_in.go | 6 - internal/validators/verify_otp.go | 6 - web/package.json | 3 + web/pnpm-lock.yaml | 190 ++++++++++ web/src/actions/accounts/index.ts | 10 + web/src/actions/accounts/mutations.ts | 12 + web/src/actions/accounts/query-options.ts | 8 + web/src/actions/auth/profile.ts | 5 + web/src/actions/auth/verify-token.ts | 7 - web/src/components/account/create.tsx | 82 ++++ web/src/components/account/switcher.tsx | 97 +++++ web/src/components/app-sidebar/index.tsx | 180 +++++++++ web/src/components/app-sidebar/nav-main.tsx | 78 ++++ web/src/components/app-sidebar/nav-user.tsx | 110 ++++++ web/src/components/applications/index.tsx | 4 + web/src/components/sign-up/form.tsx | 2 - web/src/components/ui/alert-dialog.tsx | 18 +- web/src/components/ui/avatar.tsx | 4 +- web/src/components/ui/breadcrumb.tsx | 115 ++++++ web/src/components/ui/collapsible.tsx | 9 + web/src/components/ui/credenza.tsx | 153 ++++++++ web/src/components/ui/dialog.tsx | 85 +++-- web/src/components/ui/drawer.tsx | 118 ++++++ web/src/components/ui/dropdown-menu.tsx | 199 ++++++++++ web/src/components/ui/input.tsx | 7 +- web/src/components/ui/label.tsx | 2 +- web/src/components/ui/separator.tsx | 4 +- web/src/components/ui/sheet.tsx | 14 +- web/src/components/ui/sidebar.tsx | 352 +++++++++--------- web/src/components/ui/table.tsx | 6 +- web/src/components/ui/tooltip.tsx | 6 +- web/src/global-state/accounts/index.ts | 23 ++ .../persistant-storage/selected-account.ts | 46 +++ .../global-state/persistant-storage/token.ts | 14 +- web/src/hooks/use-media-query.ts | 19 + web/src/routes/_authenticated.tsx | 58 ++- web/src/routes/_authenticated/index.tsx | 2 +- web/src/schema/account.ts | 22 ++ web/src/styles/index.css | 13 + web/tailwind.config.js | 128 +++---- 71 files changed, 2702 insertions(+), 406 deletions(-) create mode 100644 :w create mode 100644 internal/app/middlewares.go create mode 100644 internal/constants/constants.go create mode 100644 internal/controllers/account_controller.go create mode 100644 internal/controllers/applicaton_controller.go create mode 100644 internal/middlewares/auth_middleware.go create mode 100644 internal/models/account.go create mode 100644 internal/models/application.go create mode 100644 internal/models/application_auth_provider.go create mode 100644 internal/models/application_environment.go create mode 100644 internal/repositories/account_repository.go create mode 100644 internal/repositories/application_repository.go create mode 100644 internal/repositories/user_account_repository.go create mode 100644 internal/services/account_service.go create mode 100644 internal/services/application_service.go create mode 100644 internal/validators/account.go create mode 100644 internal/validators/application.go rename internal/validators/{sign_up.go => auth.go} (84%) delete mode 100644 internal/validators/sign_in.go delete mode 100644 internal/validators/verify_otp.go create mode 100644 web/src/actions/accounts/index.ts create mode 100644 web/src/actions/accounts/mutations.ts create mode 100644 web/src/actions/accounts/query-options.ts create mode 100644 web/src/actions/auth/profile.ts delete mode 100644 web/src/actions/auth/verify-token.ts create mode 100644 web/src/components/account/create.tsx create mode 100644 web/src/components/account/switcher.tsx create mode 100644 web/src/components/app-sidebar/index.tsx create mode 100644 web/src/components/app-sidebar/nav-main.tsx create mode 100644 web/src/components/app-sidebar/nav-user.tsx create mode 100644 web/src/components/applications/index.tsx create mode 100644 web/src/components/ui/breadcrumb.tsx create mode 100644 web/src/components/ui/collapsible.tsx create mode 100644 web/src/components/ui/credenza.tsx create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/components/ui/dropdown-menu.tsx create mode 100644 web/src/global-state/accounts/index.ts create mode 100644 web/src/global-state/persistant-storage/selected-account.ts create mode 100644 web/src/hooks/use-media-query.ts create mode 100644 web/src/schema/account.ts diff --git a/:w b/:w new file mode 100644 index 00000000..4dd4fbc0 --- /dev/null +++ b/:w @@ -0,0 +1,25 @@ +package controllers + +import ( + "keizer-auth/internal/services" + "keizer-auth/internal/utils" + + "github.com/gofiber/fiber/v2" +) + +type ApplicationController struct { + applicationService *services.ApplicationService +} + +func NewApplicationController( + applicationService *services.ApplicationService, +) *ApplicationController { + return &ApplicationController{ + applicationService: applicationService, + } +} + +func (self *ApplicationController) Get(c *fiber.Ctx) error { + user := utils.GetCurrentUser(c) + applications := self.applicationService.Get(user.ID, user.ID) +} diff --git a/docker-compose.yml b/docker-compose.yml index 56e94eab..3d62d33d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,13 @@ services: - "${DB_PORT}:5432" volumes: - psql_volume_bp:/var/lib/postgresql/data + networks: + - dev_network minio: image: minio/minio:latest environment: + MINIO_SERVER_URL: http://localhost:${MINIO_PORT:-9000} MINIO_ROOT_USER: ${MINIO_ROOT_USER} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} command: server /data @@ -22,6 +25,8 @@ services: volumes: - minio_data:/data restart: unless-stopped + networks: + - dev_network mailhog: image: mailhog/mailhog:latest @@ -29,6 +34,8 @@ services: - "1025:1025" # SMTP server - "8025:8025" # Web UI for email testing restart: unless-stopped + networks: + - dev_network redis: image: redis:latest @@ -37,6 +44,11 @@ services: volumes: - redis_data:/data restart: unless-stopped + networks: + - dev_network + +networks: + dev_network: volumes: psql_volume_bp: diff --git a/internal/app/container.go b/internal/app/container.go index 54120306..23990c9e 100644 --- a/internal/app/container.go +++ b/internal/app/container.go @@ -1,18 +1,19 @@ package app import ( - "sync" - "keizer-auth/internal/database" "keizer-auth/internal/repositories" "keizer-auth/internal/services" + "sync" ) type Container struct { - DB database.Service - AuthService *services.AuthService - SessionService *services.SessionService - EmailService *services.EmailService + DB database.Service + AuthService *services.AuthService + SessionService *services.SessionService + EmailService *services.EmailService + AccountService *services.AccountService + ApplicationService *services.ApplicationService } var ( @@ -27,14 +28,22 @@ func GetContainer() *Container { rds := database.NewRedisClient() userRepo := repositories.NewUserRepository(gormDB) + accountRepo := repositories.NewAccountRepository(gormDB) + applicationRepo := repositories.NewApplicationRepository(gormDB) + userAccountRepo := repositories.NewUserAccountRepository(gormDB) redisRepo := repositories.NewRedisRepository(rds) + authService := services.NewAuthService(userRepo, redisRepo) sessionService := services.NewSessionService(redisRepo, userRepo) + accountService := services.NewAccountService(accountRepo, userAccountRepo) + applicationService := services.NewApplicationService(applicationRepo, accountRepo) container = &Container{ - DB: db, - AuthService: authService, - SessionService: sessionService, + DB: db, + AuthService: authService, + SessionService: sessionService, + AccountService: accountService, + ApplicationService: applicationService, } }) return container diff --git a/internal/app/controllers.go b/internal/app/controllers.go index dfdaa24a..ce1eb109 100644 --- a/internal/app/controllers.go +++ b/internal/app/controllers.go @@ -3,11 +3,15 @@ package app import "keizer-auth/internal/controllers" type ServerControllers struct { - Auth *controllers.AuthController + Auth *controllers.AuthController + Account *controllers.AccountController + Application *controllers.ApplicationController } func GetControllers(container *Container) *ServerControllers { return &ServerControllers{ - Auth: controllers.NewAuthController(container.AuthService, container.SessionService), + Auth: controllers.NewAuthController(container.AuthService, container.SessionService), + Account: controllers.NewAccountController(container.AccountService), + Application: controllers.NewApplicationController(container.ApplicationService), } } diff --git a/internal/app/middlewares.go b/internal/app/middlewares.go new file mode 100644 index 00000000..a7ed7bb8 --- /dev/null +++ b/internal/app/middlewares.go @@ -0,0 +1,15 @@ +package app + +import ( + "keizer-auth/internal/middlewares" +) + +type ServerMiddlewares struct { + Auth *middlewares.AuthMiddleware +} + +func GetMiddlewares(container *Container) *ServerMiddlewares { + return &ServerMiddlewares{ + Auth: middlewares.NewAuthMiddleware(container.SessionService), + } +} diff --git a/internal/constants/constants.go b/internal/constants/constants.go new file mode 100644 index 00000000..f9e02bcb --- /dev/null +++ b/internal/constants/constants.go @@ -0,0 +1,3 @@ +package constants + +const UserContextKey = "currentUser" diff --git a/internal/controllers/account_controller.go b/internal/controllers/account_controller.go new file mode 100644 index 00000000..2ff141fa --- /dev/null +++ b/internal/controllers/account_controller.go @@ -0,0 +1,55 @@ +package controllers + +import ( + "keizer-auth/internal/models" + "keizer-auth/internal/services" + "keizer-auth/internal/utils" + "keizer-auth/internal/validators" + + "github.com/gofiber/fiber/v2" +) + +type AccountController struct { + accountService *services.AccountService +} + +func NewAccountController( + accountService *services.AccountService, +) *AccountController { + return &AccountController{accountService: accountService} +} + +func (self *AccountController) Get(c *fiber.Ctx) error { + user := utils.GetCurrentUser(c) + accounts, err := self.accountService.GetAccountsByUser(user.ID) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + return c.JSON(accounts) +} + +func (self *AccountController) Create(c *fiber.Ctx) error { + var err error + user := utils.GetCurrentUser(c) + body := new(validators.CreateAccount) + + if err := c.BodyParser(body); err != nil { + return c. + Status(fiber.StatusBadRequest). + JSON(fiber.Map{"error": "Invalid request body"}) + } + + if err := body.ValidateFile(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + account := new(models.Account) + account, err = self.accountService.Create(body.Name, user.ID) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + return c.JSON(account) +} diff --git a/internal/controllers/applicaton_controller.go b/internal/controllers/applicaton_controller.go new file mode 100644 index 00000000..39459401 --- /dev/null +++ b/internal/controllers/applicaton_controller.go @@ -0,0 +1,57 @@ +package controllers + +import ( + "keizer-auth/internal/models" + "keizer-auth/internal/services" + "keizer-auth/internal/utils" + "keizer-auth/internal/validators" + + "github.com/gofiber/fiber/v2" +) + +type ApplicationController struct { + applicationService *services.ApplicationService +} + +func NewApplicationController( + applicationService *services.ApplicationService, +) *ApplicationController { + return &ApplicationController{ + applicationService: applicationService, + } +} + +func (self *ApplicationController) Get(c *fiber.Ctx) error { + user := utils.GetCurrentUser(c) + applications, err := self.applicationService.Get(user.ID, user.ID) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + return c.JSON(applications) +} + +func (self *ApplicationController) Create(c *fiber.Ctx) error { + var err error + user := utils.GetCurrentUser(c) + body := new(validators.CreateApplication) + + if err := c.BodyParser(body); err != nil { + return c. + Status(fiber.StatusBadRequest). + JSON(fiber.Map{"error": "Invalid request body"}) + } + + if err := body.ValidateFile(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + account := new(models.Account) + account, err = self.applicationService.Create(body.Name, account.ID user.ID) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + + return c.JSON(account) +} diff --git a/internal/controllers/auth_controller.go b/internal/controllers/auth_controller.go index cea7852f..14b79fea 100644 --- a/internal/controllers/auth_controller.go +++ b/internal/controllers/auth_controller.go @@ -52,6 +52,9 @@ func (ac *AuthController) SignIn(c *fiber.Ctx) error { "error": "User is not verified. Please verify your account before signing in.", }) } + if user.Type != models.Dashboard { + return c.SendStatus(fiber.StatusBadRequest) + } isValid, err := ac.authService.VerifyPassword( body.Password, @@ -75,6 +78,7 @@ func (ac *AuthController) SignIn(c *fiber.Ctx) error { JSON(fiber.Map{"error": "Something went wrong, Failed to create session"}) } + fmt.Printf("%v", sessionId) fmt.Print(sessionId) utils.SetSessionCookie(c, sessionId) return c.JSON(user) @@ -152,22 +156,7 @@ func (ac *AuthController) VerifyOTP(c *fiber.Ctx) error { return c.JSON(user) } -func (ac *AuthController) VerifyTokenHandler(c *fiber.Ctx) error { - sessionID := utils.GetSessionCookie(c) - fmt.Print("\n") - fmt.Print(sessionID) - if sessionID == "" { - return c. - Status(fiber.StatusUnauthorized). - JSON(fiber.Map{"error": "Unauthorized"}) - } - - user := new(models.User) - if err := ac.sessionService.GetSession(sessionID, user); err != nil { - return c. - Status(fiber.StatusUnauthorized). - JSON(fiber.Map{"error": "Unauthorized"}) - } - +func (ac *AuthController) Profile(c *fiber.Ctx) error { + user := utils.GetCurrentUser(c) return c.JSON(user) } diff --git a/internal/database/database.go b/internal/database/database.go index 0a1e0f8e..8e0028a8 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -3,13 +3,12 @@ package database import ( "context" "fmt" + "keizer-auth/internal/models" "log" "os" "strconv" "time" - "keizer-auth/internal/models" - _ "github.com/joho/godotenv/autoload" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -42,7 +41,6 @@ var ( ) func New() Service { - // Reuse Connection if dbInstance != nil { return dbInstance } @@ -83,10 +81,39 @@ func GetDB() *gorm.DB { } func autoMigrate(db *gorm.DB) error { - return db.AutoMigrate( - &models.User{}, + user := &models.User{} + if err := user.BeforeMigrate(db); err != nil { + return err + } + + if err := db.AutoMigrate( + user, &models.Domain{}, - ) + &models.Account{}, + &models.UserAccount{}, + ); err != nil { + return err + } + + if err := db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.constraint_column_usage + WHERE table_name = 'user_accounts' + AND constraint_name = 'check_role' + ) THEN + ALTER TABLE user_accounts + ADD CONSTRAINT check_role + CHECK (role IN ('admin', 'member')); + END IF; + END $$; + `).Error; err != nil { + return err + } + + return nil } // Health checks the health of the database connection by pinging the database. diff --git a/internal/middlewares/auth_middleware.go b/internal/middlewares/auth_middleware.go new file mode 100644 index 00000000..c69a8637 --- /dev/null +++ b/internal/middlewares/auth_middleware.go @@ -0,0 +1,42 @@ +package middlewares + +import ( + "keizer-auth/internal/constants" + "keizer-auth/internal/models" + "keizer-auth/internal/services" + "keizer-auth/internal/utils" + "log" + + "github.com/gofiber/fiber/v2" +) + +type AuthMiddleware struct { + sessionService *services.SessionService +} + +func NewAuthMiddleware( + ss *services.SessionService, +) *AuthMiddleware { + return &AuthMiddleware{sessionService: ss} +} + +func (self *AuthMiddleware) Authorize(c *fiber.Ctx) error { + sessionID := utils.GetSessionCookie(c) + if sessionID == "" { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized", + }) + } + + var user models.User + err := self.sessionService.GetSession(sessionID, &user) + if err != nil { + log.Printf("Session validation error: %v", err) + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "unauthorized", + }) + } + + c.Locals(constants.UserContextKey, &user) + return c.Next() +} diff --git a/internal/models/account.go b/internal/models/account.go new file mode 100644 index 00000000..94a9af3f --- /dev/null +++ b/internal/models/account.go @@ -0,0 +1,48 @@ +package models + +import ( + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type UserAccountRole string + +const ( + RoleAdmin UserAccountRole = "admin" + RoleMember UserAccountRole = "member" +) + +type Account struct { + Name string `gorm:"not null;default:null;type:varchar(100)" json:"name"` + Logo string `gorm:"default:null" json:"logo"` + Base + Users []User `gorm:"many2many:user_accounts" json:"-"` +} + +type UserAccount struct { + UniqueConstraint string `gorm:"uniqueIndex:user_account_unique,priority:1" json:"-"` + Role UserAccountRole `gorm:"not null;type:varchar(50);default:'member'"` + Account Account `gorm:"foreignKey:AccountID"` + Base + User User `gorm:"foreignKey:UserID"` + AccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"account_id"` + UserID uuid.UUID `gorm:"type:uuid;not null;index" json:"user_id"` +} + +func (self UserAccountRole) IsValid() bool { + switch self { + case RoleAdmin, RoleMember: + return true + default: + return false + } +} + +func (self *UserAccount) BeforeSave(tx *gorm.DB) error { + if !self.Role.IsValid() { + return fmt.Errorf("invalid role: %s", self.Role) + } + return nil +} diff --git a/internal/models/application.go b/internal/models/application.go new file mode 100644 index 00000000..3863f1d6 --- /dev/null +++ b/internal/models/application.go @@ -0,0 +1,27 @@ +package models + +import ( + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type Application struct { + Name string `gorm:"not null;default:null;type:varchar(100)" json:"name"` + Logo string `gorm:"default:null" json:"logo"` + Base + Account Account `gorm:"foreignKey:AccountID"` + AccountID uuid.UUID `gorm:"type:uuid;not null;index" json:"account_id"` +} + +func (a *Application) AfterCreate(tx *gorm.DB) error { + environments := []ApplicationEnvironment{ + {Name: "development", ApplicationID: a.ID}, + {Name: "production", ApplicationID: a.ID}, + } + if err := tx.Create(&environments).Error; err != nil { + return errors.New("Failed to create default environments") + } + return nil +} diff --git a/internal/models/application_auth_provider.go b/internal/models/application_auth_provider.go new file mode 100644 index 00000000..9d5a8c98 --- /dev/null +++ b/internal/models/application_auth_provider.go @@ -0,0 +1,29 @@ +package models + +import "github.com/google/uuid" + +type AuthProviderType string + +const ( + AuthProviderEmail AuthProviderType = "email" + // AuthProviderGoogle AuthProviderType = "google" + // AuthProviderGithub AuthProviderType = "github" + // AuthProviderMicrosoft AuthProviderType = "microsoft" +) + +type ApplicationAuthProvider struct { + Provider AuthProviderType `gorm:"type:varchar(50);not null" json:"provider"` + + // Specific configuration for each provider + ClientID string `gorm:"type:varchar(255)" json:"client_id,omitempty"` + ClientSecret string `gorm:"type:varchar(255)" json:"client_secret,omitempty"` + + Base + + // Additional provider-specific configurations can be added as needed + Scopes []string `gorm:"type:text[];serializer:json" json:"scopes,omitempty"` + + Application Application `gorm:"foreignKey:ApplicationID"` + ApplicationID uuid.UUID `gorm:"type:uuid;not null;index" json:"application_id"` + IsEnabled bool `gorm:"default:false" json:"is_enabled"` +} diff --git a/internal/models/application_environment.go b/internal/models/application_environment.go new file mode 100644 index 00000000..307a66a7 --- /dev/null +++ b/internal/models/application_environment.go @@ -0,0 +1,22 @@ +package models + +import ( + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ApplicationEnvironment struct { + Name string `gorm:"not null;type:varchar(50)" json:"name"` + Status string `gorm:"type:varchar(50);default:'active'" json:"status"` + Base + Application Application `gorm:"foreignKey:ApplicationID"` + ApplicationID uuid.UUID `gorm:"type:uuid;not null;index" json:"application_id"` + IsProtected bool `gorm:"not null;default:false" json:"is_protected"` +} + +func (e *ApplicationEnvironment) BeforeCreate(tx *gorm.DB) error { + if e.Name == "development" || e.Name == "production" { + e.IsProtected = true + } + return nil +} diff --git a/internal/models/main.go b/internal/models/main.go index 9473356a..4808484e 100644 --- a/internal/models/main.go +++ b/internal/models/main.go @@ -16,6 +16,6 @@ type Base struct { } func (base *Base) BeforeCreate(tx *gorm.DB) (err error) { - base.ID = uuid.New() - return + base.ID, err = uuid.NewV7() + return err } diff --git a/internal/models/user.go b/internal/models/user.go index c9c5f19f..770512e0 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -1,6 +1,47 @@ package models -import "time" +import ( + "database/sql/driver" + "fmt" + "time" + + "gorm.io/gorm" +) + +type UserType string + +const ( + Dashboard UserType = "dashboard" + Member UserType = "member" +) + +func (self *UserType) Scan(value interface{}) error { + if value == "" { + *self = Member + return nil + } + + strVal, ok := value.(string) + if !ok { + return fmt.Errorf("Failed to convert") + } + + *self = UserType(strVal) + return nil +} + +func (self UserType) Value() (driver.Value, error) { + return string(self), nil +} + +func (ut UserType) Validate() bool { + switch ut { + case Dashboard, Member: + return true + default: + return false + } +} type User struct { LastLogin time.Time `json:"last_login"` @@ -8,7 +49,19 @@ type User struct { PasswordHash string `json:"-"` FirstName string `gorm:"not null;type:varchar(100);default:null" json:"first_name"` LastName string `gorm:"type:varchar(100);default:null" json:"last_name"` + Type UserType `gorm:"type:user_type;not null;default:'member'" json:"type"` Base IsVerified bool `gorm:"not null;default:false" json:"is_verified"` IsActive bool `gorm:"not null;default:false" json:"is_active"` } + +func (u *User) BeforeMigrate(db *gorm.DB) error { + return db.Exec(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type') THEN + CREATE TYPE user_type AS ENUM ('dashboard', 'member'); + END IF; + END$$; + `).Error +} diff --git a/internal/repositories/account_repository.go b/internal/repositories/account_repository.go new file mode 100644 index 00000000..6c465442 --- /dev/null +++ b/internal/repositories/account_repository.go @@ -0,0 +1,68 @@ +package repositories + +import ( + "fmt" + "keizer-auth/internal/models" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type AccountRepository struct { + db *gorm.DB +} + +func NewAccountRepository(db *gorm.DB) *AccountRepository { + return &AccountRepository{db: db} +} + +func (self *AccountRepository) Create(account *models.Account) error { + return self. + db.Model(account). + Clauses(clause.Returning{}). + Create(account). + Error +} + +func (self *AccountRepository) GetAccountsByUser( + userID uuid.UUID, +) (*[]models.Account, error) { + accounts := new([]models.Account) + if err := self.db. + Joins("JOIN user_accounts ON user_accounts.account_id = accounts.id"). + Where("user_accounts.user_id = ?", userID). + Find(accounts).Error; err != nil { + return nil, err + } + return accounts, nil +} + +func (self *AccountRepository) GetAccountByUser( + accountID uuid.UUID, + userID uuid.UUID, +) (*models.Account, error) { + account := new(models.Account) + if err := self. + db.Model(models.Account{}). + Joins("JOIN user_accounts ON user_accounts.account_id = accounts.id"). + Where("user_accounts.user_id = ? AND accounts.id = ?", userID, accountID). + First(account).Error; err != nil { + return nil, err + } + return account, nil +} + +func (self *AccountRepository) Get(uuid string) (*models.Account, error) { + account := new(models.Account) + result := self.db.First(&account, "id = ?", uuid) + + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("account not found") + } + return nil, fmt.Errorf("error in getting account: %w", result.Error) + } + + return account, nil +} diff --git a/internal/repositories/application_repository.go b/internal/repositories/application_repository.go new file mode 100644 index 00000000..a0c13a23 --- /dev/null +++ b/internal/repositories/application_repository.go @@ -0,0 +1,53 @@ +package repositories + +import ( + "fmt" + "keizer-auth/internal/models" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type ApplicationRepository struct { + db *gorm.DB +} + +func NewApplicationRepository(db *gorm.DB) *ApplicationRepository { + return &ApplicationRepository{db: db} +} + +func (self *ApplicationRepository) Create(application *models.Application) error { + return self. + db.Model(application). + Clauses(clause.Returning{}). + Create(application). + Error +} + +func (self *ApplicationRepository) GetByID(uuid string) (*models.Application, error) { + application := new(models.Application) + result := self.db.First(&application, "id = ?", uuid) + + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("application not found") + } + return nil, fmt.Errorf("error in getting application: %w", result.Error) + } + + return application, nil +} + +func (self *ApplicationRepository) GetApplicationsByAccount( + accountID uuid.UUID, +) (*[]models.Application, error) { + applications := new([]models.Application) + err := self.db. + Where("account_id = ?", accountID). + Find(applications).Error + if err != nil { + return nil, err + } + return applications, nil +} diff --git a/internal/repositories/user_account_repository.go b/internal/repositories/user_account_repository.go new file mode 100644 index 00000000..08fb4f86 --- /dev/null +++ b/internal/repositories/user_account_repository.go @@ -0,0 +1,26 @@ +package repositories + +import ( + "keizer-auth/internal/models" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type UserAccountRepository struct { + db *gorm.DB +} + +func NewUserAccountRepository(db *gorm.DB) *UserAccountRepository { + return &UserAccountRepository{db: db} +} + +func (self *UserAccountRepository) Create( + userAccount *models.UserAccount, +) error { + return self. + db.Model(userAccount). + Clauses(clause.Returning{}). + Create(userAccount). + Error +} diff --git a/internal/repositories/user_repository.go b/internal/repositories/user_repository.go index e4f8e2a6..6149a893 100644 --- a/internal/repositories/user_repository.go +++ b/internal/repositories/user_repository.go @@ -2,7 +2,6 @@ package repositories import ( "fmt" - "keizer-auth/internal/models" "gorm.io/gorm" @@ -39,18 +38,6 @@ func (r *UserRepository) GetUser(uuid string) (*models.User, error) { return user, nil } -func (r *UserRepository) GetUserByEmail(user *models.User) error { - result := r.db.Where(user).First(user) - if result.Error != nil { - if result.Error == gorm.ErrRecordNotFound { - return nil - } - return fmt.Errorf("error in getting user: %w", result.Error) - } - - return nil -} - func (r *UserRepository) GetUserByStruct(user *models.User) error { result := r.db.Where(user).First(user) if result.Error != nil { diff --git a/internal/server/routes.go b/internal/server/routes.go index 8227a9d2..52824e99 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -25,7 +25,17 @@ func (s *FiberServer) RegisterFiberRoutes() { auth.Post("/sign-up", s.controllers.Auth.SignUp) auth.Post("/sign-in", s.controllers.Auth.SignIn) auth.Post("/verify-otp", s.controllers.Auth.VerifyOTP) - auth.Get("/verify-token", s.controllers.Auth.VerifyTokenHandler) + auth.Get("/profile", s.middlewars.Auth.Authorize, s.controllers.Auth.Profile) + + // accounts handlers + accounts := api.Group("/accounts", s.middlewars.Auth.Authorize) + accounts.Post("/", s.controllers.Account.Create) + accounts.Get("/", s.controllers.Account.Get) + + // applications handlers + applications := accounts.Group("/:accountId/applications") + applications.Post("/", s.controllers.Application.Create) + applications.Get("/", s.controllers.Application.Get) s.Static("/", "./web/dist") s.Static("*", "./web/dist/index.html") diff --git a/internal/server/server.go b/internal/server/server.go index 6981d6f8..c05382cf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,11 +10,13 @@ type FiberServer struct { *fiber.App container *app.Container controllers *app.ServerControllers + middlewars *app.ServerMiddlewares } func New() *FiberServer { container := app.GetContainer() controllers := app.GetControllers(container) + middlewars := app.GetMiddlewares(container) server := &FiberServer{ App: fiber.New(fiber.Config{ @@ -23,6 +25,7 @@ func New() *FiberServer { }), container: container, controllers: controllers, + middlewars: middlewars, } return server diff --git a/internal/services/account_service.go b/internal/services/account_service.go new file mode 100644 index 00000000..2c3cd944 --- /dev/null +++ b/internal/services/account_service.go @@ -0,0 +1,52 @@ +package services + +import ( + "keizer-auth/internal/models" + "keizer-auth/internal/repositories" + + "github.com/google/uuid" +) + +type AccountService struct { + accountRepo *repositories.AccountRepository + userAccountRepo *repositories.UserAccountRepository +} + +func NewAccountService( + accountRepo *repositories.AccountRepository, + userAccountRepo *repositories.UserAccountRepository, +) *AccountService { + return &AccountService{accountRepo: accountRepo, userAccountRepo: userAccountRepo} +} + +func (self *AccountService) Create( + name string, + userID uuid.UUID, +) (*models.Account, error) { + account := models.Account{Name: name} + if err := self.accountRepo.Create(&account); err != nil { + return nil, err + } + + userAccount := models.UserAccount{ + UserID: userID, + AccountID: account.ID, + Role: "admin", + } + + if err := self.userAccountRepo.Create(&userAccount); err != nil { + return nil, err + } + + return &account, nil +} + +func (self *AccountService) GetAccountsByUser( + userID uuid.UUID, +) (*[]models.Account, error) { + accounts, err := self.accountRepo.GetAccountsByUser(userID) + if err != nil { + return nil, err + } + return accounts, nil +} diff --git a/internal/services/application_service.go b/internal/services/application_service.go new file mode 100644 index 00000000..c218c2f3 --- /dev/null +++ b/internal/services/application_service.go @@ -0,0 +1,65 @@ +package services + +import ( + "errors" + "keizer-auth/internal/models" + "keizer-auth/internal/repositories" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type ApplicationService struct { + applicationRepo *repositories.ApplicationRepository + accountRepo *repositories.AccountRepository +} + +func NewApplicationService( + applicationRepo *repositories.ApplicationRepository, + accountRepo *repositories.AccountRepository, +) *ApplicationService { + return &ApplicationService{ + applicationRepo: applicationRepo, + accountRepo: accountRepo, + } +} + +func (self *ApplicationService) Create( + name string, + accountID uuid.UUID, + userID uuid.UUID, +) (*models.Application, error) { + _, err := self.accountRepo.GetAccountByUser(accountID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("account not found or unauthorized access") + } + return nil, err + } + + application := models.Application{Name: name, AccountID: accountID} + if err := self.applicationRepo.Create(&application); err != nil { + return nil, err + } + return &application, nil +} + +func (self *ApplicationService) Get( + accountID uuid.UUID, + userID uuid.UUID, +) (*[]models.Application, error) { + _, err := self.accountRepo.GetAccountByUser(accountID, userID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("account not found or unauthorized access") + } + return nil, err + } + + applications, err := self.applicationRepo.GetApplicationsByAccount(accountID) + if err != nil { + return nil, err + } + + return applications, nil +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index 9f04e664..faa0ce20 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -4,12 +4,11 @@ import ( "encoding/base64" "encoding/json" "fmt" - "time" - "keizer-auth/internal/models" "keizer-auth/internal/repositories" "keizer-auth/internal/utils" "keizer-auth/internal/validators" + "time" "github.com/nrednav/cuid2" "github.com/redis/go-redis/v9" @@ -20,7 +19,10 @@ type AuthService struct { redisRepo *repositories.RedisRepository } -func NewAuthService(userRepo *repositories.UserRepository, redisRepo *repositories.RedisRepository) *AuthService { +func NewAuthService( + userRepo *repositories.UserRepository, + redisRepo *repositories.RedisRepository, +) *AuthService { return &AuthService{userRepo: userRepo, redisRepo: redisRepo} } @@ -29,10 +31,9 @@ func (as *AuthService) RegisterUser( ) (string, error) { user := models.User{ Email: userRegister.Email, + Type: models.Dashboard, } - fmt.Print(user) - fmt.Print(user.IsVerified) err := as.userRepo.GetUserByStruct(&user) if err != nil { return "", err @@ -96,7 +97,9 @@ func (as *AuthService) VerifyPassword( return utils.VerifyPassword(password, passwordHash) } -func (as *AuthService) VerifyOTP(verifyOtpBody *validators.VerifyOTP) (string, bool, error) { +func (as *AuthService) VerifyOTP( + verifyOtpBody *validators.VerifyOTP, +) (string, bool, error) { encodedOtpData, err := as.redisRepo.Get(verifyOtpBody.Id) if err != nil { if err == redis.Nil { diff --git a/internal/services/session_service.go b/internal/services/session_service.go index ef25f1d0..8ebd134a 100644 --- a/internal/services/session_service.go +++ b/internal/services/session_service.go @@ -16,7 +16,10 @@ type SessionService struct { userRepo *repositories.UserRepository } -func NewSessionService(redisRepo *repositories.RedisRepository, userRepo *repositories.UserRepository) *SessionService { +func NewSessionService( + redisRepo *repositories.RedisRepository, + userRepo *repositories.UserRepository, +) *SessionService { return &SessionService{redisRepo: redisRepo, userRepo: userRepo} } diff --git a/internal/utils/session_helpers.go b/internal/utils/session_helpers.go index a21a2425..e95331dc 100644 --- a/internal/utils/session_helpers.go +++ b/internal/utils/session_helpers.go @@ -1,6 +1,8 @@ package utils import ( + "keizer-auth/internal/constants" + "keizer-auth/internal/models" "time" "github.com/gofiber/fiber/v2" @@ -21,10 +23,17 @@ func SetSessionCookie(c *fiber.Ctx, sessionID string) { HTTPOnly: true, Secure: false, SameSite: fiber.CookieSameSiteNoneMode, - // TODO: handle domain }) } func GetSessionCookie(c *fiber.Ctx) string { return c.Cookies("session_id", "") } + +func GetCurrentUser(c *fiber.Ctx) *models.User { + user, ok := c.Locals(constants.UserContextKey).(*models.User) + if !ok { + return nil + } + return user +} diff --git a/internal/validators/account.go b/internal/validators/account.go new file mode 100644 index 00000000..5f1d2d74 --- /dev/null +++ b/internal/validators/account.go @@ -0,0 +1,31 @@ +package validators + +import ( + "errors" + "mime/multipart" +) + +type CreateAccount struct { + Logo *multipart.FileHeader `json:"-" form:"logo"` + Name string `validate:"required|maxLen:100" form:"name" json:"name" label:"Account Name"` +} + +func (self CreateAccount) ValidateFile() error { + if self.Logo == nil { + return nil + } + + const maxFileSize = 2 * 1024 * 1024 + if self.Logo.Size > maxFileSize { + return errors.New("file size must not exceed 2 MB") + } + + validTypes := []string{"image/png", "image/jpeg"} + for _, t := range validTypes { + if self.Logo.Header.Get("Content-Type") == t { + return nil + } + } + + return errors.New("file must be a PNG or JPEG image") +} diff --git a/internal/validators/application.go b/internal/validators/application.go new file mode 100644 index 00000000..1271fa99 --- /dev/null +++ b/internal/validators/application.go @@ -0,0 +1,31 @@ +package validators + +import ( + "errors" + "mime/multipart" +) + +type CreateApplication struct { + Logo *multipart.FileHeader `json:"-" form:"logo"` + Name string `validate:"required|maxLen:100" form:"name" json:"name" label:"Application Name"` +} + +func (self CreateApplication) ValidateFile() error { + if self.Logo == nil { + return nil + } + + const maxFileSize = 2 * 1024 * 1024 + if self.Logo.Size > maxFileSize { + return errors.New("file size must not exceed 2 MB") + } + + validTypes := []string{"image/png", "image/jpeg"} + for _, t := range validTypes { + if self.Logo.Header.Get("Content-Type") == t { + return nil + } + } + + return errors.New("file must be a PNG or JPEG image") +} diff --git a/internal/validators/sign_up.go b/internal/validators/auth.go similarity index 84% rename from internal/validators/sign_up.go rename to internal/validators/auth.go index c7f0c174..d148d157 100644 --- a/internal/validators/sign_up.go +++ b/internal/validators/auth.go @@ -1,9 +1,8 @@ package validators import ( - "unicode" - "keizer-auth/internal/utils" + "unicode" "github.com/gookit/validate" ) @@ -38,7 +37,7 @@ type SignUpUser struct { LastName string `json:"last_name" validate:"maxLen:32" label:"Last Name"` } -func (f SignUpUser) Messages() map[string]string { +func (f *SignUpUser) Messages() map[string]string { return validate.MS{ // Global messages "required": "{field} is required", @@ -67,3 +66,13 @@ func (u *SignUpUser) Validate() (bool, map[string]map[string]string) { return true, nil } + +type SignInUser struct { + Email string `validate:"required|email" label:"Email"` + Password string `validate:"required|minLen:8|password" label:"Password"` +} + +type VerifyOTP struct { + Otp string `validate:"required" label:"OTP"` + Id string `validate:"required" label:"Id"` +} diff --git a/internal/validators/sign_in.go b/internal/validators/sign_in.go deleted file mode 100644 index dd7ae5fb..00000000 --- a/internal/validators/sign_in.go +++ /dev/null @@ -1,6 +0,0 @@ -package validators - -type SignInUser struct { - Email string `validate:"required|email" label:"Email"` - Password string `validate:"required|minLen:8|password" label:"Password"` -} diff --git a/internal/validators/verify_otp.go b/internal/validators/verify_otp.go deleted file mode 100644 index ad243276..00000000 --- a/internal/validators/verify_otp.go +++ /dev/null @@ -1,6 +0,0 @@ -package validators - -type VerifyOTP struct { - Otp string `validate:"required" label:"OTP"` - Id string `validate:"required" label:"Id"` -} diff --git a/web/package.json b/web/package.json index 8a7d066a..e40c1894 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,9 @@ "@hookform/resolvers": "^3.9.1", "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-avatar": "^1.1.1", + "@radix-ui/react-collapsible": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", + "@radix-ui/react-dropdown-menu": "^2.1.2", "@radix-ui/react-icons": "^1.3.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-separator": "^1.1.0", @@ -35,6 +37,7 @@ "sonner": "^1.7.0", "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.1", "zod": "^3.23.8", "zustand": "^5.0.1" }, diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 64511f24..733a7542 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.1 version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.2 + version: 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-icons': specifier: ^1.3.1 version: 1.3.1(react@18.3.1) @@ -83,6 +89,9 @@ importers: tailwindcss-animate: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14) + vaul: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) zod: specifier: ^3.23.8 version: 3.23.8 @@ -682,6 +691,32 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.1': + resolution: {integrity: sha512-1///SnrfQHJEofLokyczERxQbWfCGQlQ2XsCZMucVs6it+lq9iw4vXy+uDn1edlb58cOZOWSldnfPAYcT4O/Yg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.0': + resolution: {integrity: sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.0': resolution: {integrity: sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==} peerDependencies: @@ -722,6 +757,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-direction@1.1.0': + resolution: {integrity: sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.1': resolution: {integrity: sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==} peerDependencies: @@ -735,6 +779,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-dropdown-menu@2.1.2': + resolution: {integrity: sha512-GVZMR+eqK8/Kes0a36Qrv+i20bAPXSn8rCBTHx30w+3ECnR5o3xixAlqcVaYvLeyKUsm0aqyhWfmUcqufM8nYA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-focus-guards@1.1.1': resolution: {integrity: sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==} peerDependencies: @@ -784,6 +841,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-menu@2.1.2': + resolution: {integrity: sha512-lZ0R4qR2Al6fZ4yCCZzu/ReTFrylHFxIqy7OezIpWF4bL0o9biKo0pFIvkaew3TyZ9Fy5gYVrR5zCGZBVbO1zg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.0': resolution: {integrity: sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==} peerDependencies: @@ -836,6 +906,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.0': + resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-separator@1.1.0': resolution: {integrity: sha512-3uBAs+egzvJBDZAzvb/n4NxxOYpnspmWxO2u5NbZ8Y6FM/NdrGSF9bop3Cf6F6C71z1rTSn8KV0Fo2ZVd79lGA==} peerDependencies: @@ -2266,6 +2349,12 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vaul@1.1.1: + resolution: {integrity: sha512-+ejzF6ffQKPcfgS7uOrGn017g39F8SO4yLPXbBhpC7a0H+oPqPna8f1BUfXaz8eU4+pxbQcmjxW+jWBSbxjaFg==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 + vite@5.4.10: resolution: {integrity: sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2761,6 +2850,34 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-collapsible@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + + '@radix-ui/react-collection@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-compose-refs@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -2801,6 +2918,12 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-direction@1.1.0(@types/react@18.3.12)(react@18.3.1)': + dependencies: + react: 18.3.1 + optionalDependencies: + '@types/react': 18.3.12 + '@radix-ui/react-dismissable-layer@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/primitive': 1.1.0 @@ -2814,6 +2937,21 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-dropdown-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-menu': 2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-focus-guards@1.1.1(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -2851,6 +2989,32 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-menu@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-popper': 1.2.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-portal': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.12)(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-popper@1.2.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -2898,6 +3062,23 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-separator@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -4230,6 +4411,15 @@ snapshots: util-deprecate@1.0.2: {} + vaul@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@radix-ui/react-dialog': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite@5.4.10: dependencies: esbuild: 0.21.5 diff --git a/web/src/actions/accounts/index.ts b/web/src/actions/accounts/index.ts new file mode 100644 index 00000000..a2cb5015 --- /dev/null +++ b/web/src/actions/accounts/index.ts @@ -0,0 +1,10 @@ +import apiClient from "~/axios"; +import { setAccounts } from "~/global-state/accounts"; +import type { AccountInterface } from "~/schema/account"; + +export const getAccounts = async () => { + return apiClient.get("accounts").then((r) => { + setAccounts(r.data); + return r.data; + }); +}; diff --git a/web/src/actions/accounts/mutations.ts b/web/src/actions/accounts/mutations.ts new file mode 100644 index 00000000..8126cd3d --- /dev/null +++ b/web/src/actions/accounts/mutations.ts @@ -0,0 +1,12 @@ +import apiClient from "~/axios"; +import { AccountInterface, CreateAccountInterface } from "~/schema/account"; + +export const createAccountFn = async (values: CreateAccountInterface) => { + const formData = new FormData(); + formData.append("name", values.name); + if (values.logo) formData.append("logo", values.logo); + + return apiClient + .post("/accounts", formData) + .then((r) => r.data); +}; diff --git a/web/src/actions/accounts/query-options.ts b/web/src/actions/accounts/query-options.ts new file mode 100644 index 00000000..d1714941 --- /dev/null +++ b/web/src/actions/accounts/query-options.ts @@ -0,0 +1,8 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { getAccounts } from "./index"; + +export const getAccountQueryOption = queryOptions({ + queryKey: ["get-accounts"], + queryFn: getAccounts, +}); diff --git a/web/src/actions/auth/profile.ts b/web/src/actions/auth/profile.ts new file mode 100644 index 00000000..8cf29c69 --- /dev/null +++ b/web/src/actions/auth/profile.ts @@ -0,0 +1,5 @@ +import apiClient from "~/axios"; +import { UserInterface } from "~/schema/user"; + +export const profile = async () => + await apiClient.get("auth/profile").then((res) => res.data); diff --git a/web/src/actions/auth/verify-token.ts b/web/src/actions/auth/verify-token.ts deleted file mode 100644 index 1faf9aed..00000000 --- a/web/src/actions/auth/verify-token.ts +++ /dev/null @@ -1,7 +0,0 @@ -import apiClient from "~/axios"; -import { UserInterface } from "~/schema/user"; - -export const verifyToken = async () => - await apiClient - .get("auth/verify-token") - .then((res) => res.data); diff --git a/web/src/components/account/create.tsx b/web/src/components/account/create.tsx new file mode 100644 index 00000000..db22dc5d --- /dev/null +++ b/web/src/components/account/create.tsx @@ -0,0 +1,82 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation } from "@tanstack/react-query"; +import { Dispatch, SetStateAction } from "react"; +import { useForm } from "react-hook-form"; + +import { createAccountFn } from "~/actions/accounts/mutations"; +import useAccountStore from "~/global-state/accounts"; +import { CreateAccountInterface, createAccountSchema } from "~/schema/account"; + +import { Button } from "../ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../ui/dialog"; +import { Form, FormField } from "../ui/form"; +import { Input } from "../ui/input"; + +interface Props { + open: boolean; + setOpen: Dispatch>; +} + +export const CreateAccount = ({ open, setOpen }: Props) => { + const { data: accounts, setData: setAccounts } = useAccountStore(); + + const form = useForm({ + resolver: zodResolver(createAccountSchema), + }); + + const { mutate, isPending } = useMutation({ + mutationFn: createAccountFn, + onSuccess: (data) => { + setAccounts([...accounts.filter((a) => a.id !== data.id), data]); + setOpen(false); + }, + }); + + function onOpenChange(value: boolean) { + if (!value && isPending) return; + return setOpen(value); + } + + function onSubmit(values: CreateAccountInterface) { + mutate(values); + } + + return ( + + + + Create Account + + +
+ + ( + + )} + /> + + + + + + +
+
+ ); +}; diff --git a/web/src/components/account/switcher.tsx b/web/src/components/account/switcher.tsx new file mode 100644 index 00000000..d06c82de --- /dev/null +++ b/web/src/components/account/switcher.tsx @@ -0,0 +1,97 @@ +import { ChevronsUpDown, GalleryVerticalEnd, Plus } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "~/components/ui/sidebar"; +import useAccountStore from "~/global-state/accounts"; +import useActiveAccountStore from "~/global-state/persistant-storage/selected-account"; + +import { buttonVariants } from "../ui/button"; +import { CreateAccount } from "./create"; + +export function AccountSwitcher() { + const { isMobile } = useSidebar(); + const accounts = useAccountStore.use.data(); + const { data: activeAccountId, setData: setActiveAccount } = + useActiveAccountStore(); + + const [openCreateAccount, setOpenCreateAccount] = useState(false); + + const activeAccount = useMemo(() => { + if (!activeAccountId) return null; + return accounts.find((a) => a.id === activeAccountId); + }, [accounts, activeAccountId]); + + useEffect(() => { + if (accounts && accounts.length > 0 && !activeAccountId) { + setActiveAccount(accounts[0].id); + } + }, [accounts, activeAccountId, setActiveAccount]); + + return ( + + + + + + + + + {activeAccount?.name ?? "-"} + + + + + + Accounts + + + {accounts.map((account) => ( + setActiveAccount(account.id)} + className="gap-2 p-2" + > + {account.name} + + ))} + + setOpenCreateAccount(true)} + className="gap-2 p-2" + > +
+ +
+
Add team
+
+
+
+
+
+ ); +} diff --git a/web/src/components/app-sidebar/index.tsx b/web/src/components/app-sidebar/index.tsx new file mode 100644 index 00000000..54373362 --- /dev/null +++ b/web/src/components/app-sidebar/index.tsx @@ -0,0 +1,180 @@ +import { useQuery } from "@tanstack/react-query"; +import { + BookOpen, + Bot, + Frame, + LifeBuoy, + Map, + PieChart, + Send, + Settings2, + SquareTerminal, +} from "lucide-react"; +import * as React from "react"; +import { useEffect } from "react"; + +import { getAccountQueryOption } from "~/actions/accounts/query-options"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + useSidebar, +} from "~/components/ui/sidebar"; + +import { AccountSwitcher } from "../account/switcher"; +import { NavMain } from "./nav-main"; +import { NavUser } from "./nav-user"; + +const data = { + user: { + name: "shadcn", + email: "m@example.com", + avatar: "/avatars/shadcn.jpg", + }, + navMain: [ + { + title: "Playground", + url: "#", + icon: SquareTerminal, + isActive: true, + items: [ + { + title: "History", + url: "#", + }, + { + title: "Starred", + url: "#", + }, + { + title: "Settings", + url: "#", + }, + ], + }, + { + title: "Models", + url: "#", + icon: Bot, + items: [ + { + title: "Genesis", + url: "#", + }, + { + title: "Explorer", + url: "#", + }, + { + title: "Quantum", + url: "#", + }, + ], + }, + { + title: "Documentation", + url: "#", + icon: BookOpen, + items: [ + { + title: "Introduction", + url: "#", + }, + { + title: "Get Started", + url: "#", + }, + { + title: "Tutorials", + url: "#", + }, + { + title: "Changelog", + url: "#", + }, + ], + }, + { + title: "Settings", + url: "#", + icon: Settings2, + items: [ + { + title: "General", + url: "#", + }, + { + title: "Team", + url: "#", + }, + { + title: "Billing", + url: "#", + }, + { + title: "Limits", + url: "#", + }, + ], + }, + ], + navSecondary: [ + { + title: "Support", + url: "#", + icon: LifeBuoy, + }, + { + title: "Feedback", + url: "#", + icon: Send, + }, + ], + projects: [ + { + name: "Design Engineering", + url: "#", + icon: Frame, + }, + { + name: "Sales & Marketing", + url: "#", + icon: PieChart, + }, + { + name: "Travel", + url: "#", + icon: Map, + }, + ], +}; + +export function AppSidebar({ ...props }: React.ComponentProps) { + const { setOpenMobile } = useSidebar(); + const { data: accounts, isPending } = useQuery(getAccountQueryOption); + + useEffect(() => { + if (accounts && accounts.length === 0) setOpenMobile(true); + }, [accounts, setOpenMobile]); + + if (isPending) { + return; + } + + return ( + + + + + + + + + + + + + + ); +} diff --git a/web/src/components/app-sidebar/nav-main.tsx b/web/src/components/app-sidebar/nav-main.tsx new file mode 100644 index 00000000..9d496e6f --- /dev/null +++ b/web/src/components/app-sidebar/nav-main.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { ChevronRight, type LucideIcon } from "lucide-react"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "~/components/ui/collapsible"; +import { + SidebarGroup, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, +} from "~/components/ui/sidebar"; + +export function NavMain({ + items, +}: { + items: { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + items?: { + title: string; + url: string; + }[]; + }[]; +}) { + return ( + + Platform + + {items.map((item) => ( + + + + + + {item.title} + + + {item.items?.length ? ( + <> + + + + Toggle + + + + + {item.items?.map((subItem) => ( + + + + {subItem.title} + + + + ))} + + + + ) : null} + + + ))} + + + ); +} diff --git a/web/src/components/app-sidebar/nav-user.tsx b/web/src/components/app-sidebar/nav-user.tsx new file mode 100644 index 00000000..c8e15114 --- /dev/null +++ b/web/src/components/app-sidebar/nav-user.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { + BadgeCheck, + Bell, + ChevronsUpDown, + CreditCard, + LogOut, + Sparkles, +} from "lucide-react"; + +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "~/components/ui/sidebar"; + +export function NavUser({ + user, +}: { + user: { + name: string; + email: string; + avatar: string; + }; +}) { + const { isMobile } = useSidebar(); + + return ( + + + + + + + + CN + +
+ {user.name} + {user.email} +
+ +
+
+ + +
+ + + CN + +
+ {user.name} + {user.email} +
+
+
+ + + + + Upgrade to Pro + + + + + + + Account + + + + Billing + + + + Notifications + + + + + + Log out + +
+
+
+
+ ); +} diff --git a/web/src/components/applications/index.tsx b/web/src/components/applications/index.tsx new file mode 100644 index 00000000..d73c792d --- /dev/null +++ b/web/src/components/applications/index.tsx @@ -0,0 +1,4 @@ +// export default function Application() { +// const +// return +// } diff --git a/web/src/components/sign-up/form.tsx b/web/src/components/sign-up/form.tsx index 40cba915..f9c90cee 100644 --- a/web/src/components/sign-up/form.tsx +++ b/web/src/components/sign-up/form.tsx @@ -1,5 +1,3 @@ -"use client"; - import { zodResolver } from "@hookform/resolvers/zod"; import { GitHubLogoIcon } from "@radix-ui/react-icons"; import { useMutation } from "@tanstack/react-query"; diff --git a/web/src/components/ui/alert-dialog.tsx b/web/src/components/ui/alert-dialog.tsx index 9c983585..bff9cc77 100644 --- a/web/src/components/ui/alert-dialog.tsx +++ b/web/src/components/ui/alert-dialog.tsx @@ -1,10 +1,10 @@ "use client" -import * as React from "react" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import * as React from "react" -import { cn } from "~/lib/utils" import { buttonVariants } from "~/components/ui/button" +import { cn } from "~/lib/utils" const AlertDialog = AlertDialogPrimitive.Root @@ -128,14 +128,14 @@ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName export { AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, + AlertDialogAction, + AlertDialogCancel, AlertDialogContent, - AlertDialogHeader, + AlertDialogDescription, AlertDialogFooter, + AlertDialogHeader, + AlertDialogOverlay, + AlertDialogPortal, AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, + AlertDialogTrigger, } diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx index 706f1778..a24bbc5a 100644 --- a/web/src/components/ui/avatar.tsx +++ b/web/src/components/ui/avatar.tsx @@ -1,5 +1,5 @@ -import * as React from "react" import * as AvatarPrimitive from "@radix-ui/react-avatar" +import * as React from "react" import { cn } from "~/lib/utils" @@ -45,4 +45,4 @@ const AvatarFallback = React.forwardRef< )) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName -export { Avatar, AvatarImage, AvatarFallback } +export { Avatar, AvatarFallback,AvatarImage } diff --git a/web/src/components/ui/breadcrumb.tsx b/web/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..fc7d5307 --- /dev/null +++ b/web/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons" +import { Slot } from "@radix-ui/react-slot" +import * as React from "react" + +import { cn } from "~/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>